From 1edd4c95bc8e51d34ea416effae2bf9d36e68f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B8=AD=E9=98=B3=E9=98=B3?= Date: Tue, 20 Jan 2026 20:10:27 +0800 Subject: [PATCH] feat: add openwork --- openwork-memos-integration/CLAUDE.md | 162 + openwork-memos-integration/CONTRIBUTING.md | 60 + openwork-memos-integration/LICENSE | 21 + openwork-memos-integration/README.md | 315 + openwork-memos-integration/SECURITY.md | 49 + .../apps/desktop/.eslintrc.json | 39 + .../main/appSettings.integration.test.ts | 369 + .../opencode/cli-path.integration.test.ts | 499 + .../config-generator.integration.test.ts | 332 + .../main/permission-api.integration.test.ts | 120 + .../main/secureStorage.integration.test.ts | 519 + .../freshInstallCleanup.integration.test.ts | 244 + .../main/taskHistory.integration.test.ts | 625 ++ .../utils/bundled-node.integration.test.ts | 449 + .../utils/system-path.integration.test.ts | 513 + .../preload/preload.integration.test.ts | 323 + .../renderer/App.integration.test.tsx | 370 + .../components/Header.integration.test.tsx | 272 + .../SettingsDialog.integration.test.tsx | 854 ++ .../components/Sidebar.integration.test.tsx | 522 + .../StreamingText.integration.test.tsx | 487 + .../TaskHistory.integration.test.tsx | 791 ++ .../TaskInputBar.integration.test.tsx | 526 + .../TaskLauncher.integration.test.tsx | 1122 ++ .../pages/Execution.integration.test.tsx | 1380 +++ .../renderer/pages/Home.integration.test.tsx | 594 ++ .../renderer/taskStore.integration.test.ts | 869 ++ .../__tests__/main/config.unit.test.ts | 197 + .../main/ipc/handlers-utils.unit.test.ts | 784 ++ .../main/ipc/validation.unit.test.ts | 617 ++ .../main/opencode/stream-parser.unit.test.ts | 692 ++ .../__tests__/renderer/lib/utils.unit.test.ts | 437 + .../apps/desktop/__tests__/setup.ts | 13 + .../unit/main/ipc/handlers.unit.test.ts | 1946 ++++ .../unit/main/opencode/adapter.unit.test.ts | 856 ++ .../main/opencode/task-manager.unit.test.ts | 727 ++ .../unit/renderer/lib/accomplish.unit.test.ts | 234 + .../unit/renderer/lib/analytics.unit.test.ts | 281 + .../unit/renderer/lib/animations.unit.test.ts | 141 + .../lib/waiting-detection.unit.test.ts | 185 + .../apps/desktop/clean_dmg_install.sh | 229 + .../apps/desktop/e2e/README.md | 236 + .../apps/desktop/e2e/config/index.ts | 1 + .../apps/desktop/e2e/config/timeouts.ts | 62 + .../apps/desktop/e2e/docker/Dockerfile | 52 + .../desktop/e2e/docker/docker-compose.yml | 20 + .../apps/desktop/e2e/fixtures/electron-app.ts | 67 + .../apps/desktop/e2e/fixtures/index.ts | 1 + .../apps/desktop/e2e/pages/execution.page.ts | 71 + .../apps/desktop/e2e/pages/home.page.ts | 37 + .../apps/desktop/e2e/pages/index.ts | 3 + .../apps/desktop/e2e/pages/settings.page.ts | 247 + .../apps/desktop/e2e/playwright.config.ts | 47 + .../apps/desktop/e2e/specs/execution.spec.ts | 618 ++ .../apps/desktop/e2e/specs/home.spec.ts | 215 + .../e2e/specs/settings-bedrock.spec.ts | 187 + .../e2e/specs/settings-providers.spec.ts | 351 + .../apps/desktop/e2e/specs/settings.spec.ts | 750 ++ .../e2e/specs/task-launch-guard.spec.ts | 303 + .../apps/desktop/e2e/utils/index.ts | 1 + .../apps/desktop/e2e/utils/screenshots.ts | 109 + .../apps/desktop/index.html | 21 + .../apps/desktop/package.json | 178 + .../apps/desktop/postcss.config.js | 6 + .../public/assets/ai-logos/anthropic.svg | 3 + .../public/assets/ai-logos/bedrock.svg | 10 + .../public/assets/ai-logos/deepseek.svg | 10 + .../desktop/public/assets/ai-logos/google.svg | 110 + .../public/assets/ai-logos/litellm.svg | 9 + .../desktop/public/assets/ai-logos/ollama.svg | 3 + .../desktop/public/assets/ai-logos/openai.svg | 3 + .../public/assets/ai-logos/openrouter.svg | 3 + .../public/assets/ai-logos/provider-logos.svg | 147 + .../desktop/public/assets/ai-logos/vertex.svg | 10 + .../desktop/public/assets/ai-logos/xai.svg | 6 + .../desktop/public/assets/ai-logos/zai.svg | 6 + .../desktop/public/assets/icons/connect.svg | 3 + .../public/assets/icons/connected-key.svg | 12 + .../desktop/public/assets/icons/connected.svg | 3 + .../public/assets/icons/pending-key.svg | 12 + .../desktop/public/assets/loading-symbol.svg | 8 + .../apps/desktop/public/assets/logo-1.png | Bin 0 -> 3636 bytes .../apps/desktop/public/assets/logo.png | Bin 0 -> 4605 bytes .../desktop/public/assets/openwork-icon.png | Bin 0 -> 1003 bytes .../assets/usecases/ai-image-wizard.webp | Bin 0 -> 2828 bytes .../assets/usecases/automated-reminders.webp | Bin 0 -> 22282 bytes .../assets/usecases/batch-file-renaming.webp | Bin 0 -> 49778 bytes .../assets/usecases/bilingual-output.webp | Bin 0 -> 18690 bytes .../assets/usecases/calendar-events.webp | Bin 0 -> 22008 bytes .../assets/usecases/calendar-prep-notes.png | Bin 0 -> 12480 bytes .../assets/usecases/career-document.webp | Bin 0 -> 23348 bytes .../assets/usecases/clean-data-output.webp | Bin 0 -> 18054 bytes .../usecases/competitor-pricing-deck.png | Bin 0 -> 31960 bytes .../assets/usecases/course-announcement.webp | Bin 0 -> 49226 bytes .../assets/usecases/custom-web-tool.webp | Bin 0 -> 21396 bytes .../assets/usecases/document-translation.webp | Bin 0 -> 19248 bytes .../usecases/event-calendar-builder.png | Bin 0 -> 10030 bytes .../assets/usecases/export-to-table.webp | Bin 0 -> 45222 bytes .../assets/usecases/inbox-promo-cleanup.png | Bin 0 -> 6400 bytes .../usecases/job-application-automation.png | Bin 0 -> 3065 bytes .../assets/usecases/landing-page-copy.webp | Bin 0 -> 20858 bytes .../assets/usecases/localize-content.webp | Bin 0 -> 19108 bytes .../assets/usecases/notion-api-audit.png | Bin 0 -> 5158 bytes .../public/assets/usecases/organize-data.webp | Bin 0 -> 1836 bytes .../assets/usecases/personal-website.webp | Bin 0 -> 1758 bytes .../public/assets/usecases/pitch-deck.webp | Bin 0 -> 1430 bytes .../assets/usecases/polish-writing.webp | Bin 0 -> 2928 bytes .../usecases/portfolio-presentation.webp | Bin 0 -> 19626 bytes .../assets/usecases/prod-broken-links.png | Bin 0 -> 31960 bytes .../assets/usecases/professional-emails.webp | Bin 0 -> 1376 bytes .../usecases/professional-headshot.webp | Bin 0 -> 27364 bytes .../usecases/staging-vs-prod-visual.png | Bin 0 -> 33944 bytes .../usecases/stock-portfolio-alerts.png | Bin 0 -> 6425 bytes .../desktop/public/fonts/DMSans-Black.ttf | Bin 0 -> 56344 bytes .../apps/desktop/public/fonts/DMSans-Bold.ttf | Bin 0 -> 56268 bytes .../desktop/public/fonts/DMSans-Light.ttf | Bin 0 -> 56328 bytes .../desktop/public/fonts/DMSans-Medium.ttf | Bin 0 -> 56376 bytes .../desktop/public/fonts/DMSans-Regular.ttf | Bin 0 -> 56344 bytes .../desktop/resources/entitlements.mac.plist | 18 + .../apps/desktop/resources/icon.png | Bin 0 -> 26734 bytes .../apps/desktop/run_local_ui_prod_api.sh | 4 + .../apps/desktop/run_local_ui_staging_api.sh | 4 + .../apps/desktop/run_prod.sh | 14 + .../apps/desktop/run_staging.sh | 14 + .../apps/desktop/scripts/after-pack.cjs | 256 + .../apps/desktop/scripts/download-nodejs.cjs | 205 + .../apps/desktop/scripts/package.cjs | 57 + .../desktop/scripts/patch-electron-name.cjs | 46 + .../desktop/skills/ask-user-question/SKILL.md | 133 + .../ask-user-question/package-lock.json | 1650 +++ .../skills/ask-user-question/package.json | 17 + .../skills/ask-user-question/src/index.ts | 196 + .../skills/ask-user-question/tsconfig.json | 14 + .../desktop/skills/dev-browser/.gitignore | 4 + .../apps/desktop/skills/dev-browser/SKILL.md | 211 + .../apps/desktop/skills/dev-browser/bun.lock | 443 + .../skills/dev-browser/package-lock.json | 3006 ++++++ .../desktop/skills/dev-browser/package.json | 31 + .../skills/dev-browser/references/scraping.md | 155 + .../skills/dev-browser/scripts/start-relay.ts | 33 + .../dev-browser/scripts/start-server.ts | 172 + .../apps/desktop/skills/dev-browser/server.sh | 27 + .../desktop/skills/dev-browser/src/client.ts | 509 + .../desktop/skills/dev-browser/src/index.ts | 324 + .../desktop/skills/dev-browser/src/relay.ts | 732 ++ .../src/snapshot/__tests__/snapshot.test.ts | 223 + .../src/snapshot/browser-script.ts | 877 ++ .../skills/dev-browser/src/snapshot/index.ts | 14 + .../skills/dev-browser/src/snapshot/inject.ts | 13 + .../desktop/skills/dev-browser/src/types.ts | 36 + .../desktop/skills/dev-browser/tsconfig.json | 34 + .../skills/dev-browser/vitest.config.ts | 12 + .../skills/file-permission/package-lock.json | 1650 +++ .../skills/file-permission/package.json | 17 + .../skills/file-permission/src/index.ts | 138 + .../skills/file-permission/tsconfig.json | 24 + .../skills/safe-file-deletion/SKILL.md | 50 + .../apps/desktop/src/main/config.ts | 30 + .../apps/desktop/src/main/index.ts | 223 + .../apps/desktop/src/main/ipc/handlers.ts | 1721 +++ .../apps/desktop/src/main/ipc/validation.ts | 47 + .../apps/desktop/src/main/opencode/adapter.ts | 784 ++ .../desktop/src/main/opencode/cli-path.ts | 215 + .../src/main/opencode/config-generator.ts | 728 ++ .../src/main/opencode/stream-parser.ts | 145 + .../desktop/src/main/opencode/task-manager.ts | 650 ++ .../apps/desktop/src/main/permission-api.ts | 356 + .../apps/desktop/src/main/services/memory.ts | 329 + .../desktop/src/main/services/summarizer.ts | 212 + .../desktop/src/main/store/appSettings.ts | 140 + .../src/main/store/freshInstallCleanup.ts | 265 + .../src/main/store/providerSettings.ts | 125 + .../desktop/src/main/store/secureStorage.ts | 269 + .../desktop/src/main/store/taskHistory.ts | 224 + .../src/main/test-utils/mock-task-flow.ts | 363 + .../desktop/src/main/utils/bundled-node.ts | 148 + .../desktop/src/main/utils/system-path.ts | 230 + .../apps/desktop/src/preload/index.ts | 220 + .../apps/desktop/src/renderer/App.tsx | 144 + .../components/TaskLauncher/TaskLauncher.tsx | 221 + .../TaskLauncher/TaskLauncherItem.tsx | 64 + .../renderer/components/TaskLauncher/index.ts | 2 + .../components/history/TaskHistory.tsx | 133 + .../components/landing/TaskInputBar.tsx | 96 + .../layout/ConversationListItem.tsx | 84 + .../src/renderer/components/layout/Header.tsx | 57 + .../components/layout/SettingsDialog.tsx | 1660 +++ .../renderer/components/layout/Sidebar.tsx | 137 + .../components/settings/ProviderCard.tsx | 110 + .../components/settings/ProviderGrid.tsx | 121 + .../settings/ProviderSettingsPanel.tsx | 103 + .../settings/hooks/useProviderSettings.ts | 102 + .../providers/BedrockProviderForm.tsx | 255 + .../providers/ClassicProviderForm.tsx | 186 + .../providers/LiteLLMProviderForm.tsx | 157 + .../settings/providers/OllamaProviderForm.tsx | 130 + .../providers/OpenRouterProviderForm.tsx | 196 + .../components/settings/providers/index.ts | 7 + .../settings/shared/ApiKeyInput.tsx | 62 + .../settings/shared/ConnectButton.tsx | 35 + .../settings/shared/ConnectedControls.tsx | 31 + .../settings/shared/ConnectionStatus.tsx | 63 + .../components/settings/shared/FormError.tsx | 13 + .../settings/shared/ModelSelector.tsx | 172 + .../settings/shared/ProviderFormHeader.tsx | 22 + .../settings/shared/RegionSelector.tsx | 42 + .../components/settings/shared/index.ts | 10 + .../src/renderer/components/ui/avatar.tsx | 53 + .../src/renderer/components/ui/badge.tsx | 46 + .../src/renderer/components/ui/button.tsx | 60 + .../src/renderer/components/ui/card.tsx | 92 + .../src/renderer/components/ui/dialog.tsx | 161 + .../renderer/components/ui/dropdown-menu.tsx | 251 + .../src/renderer/components/ui/input.tsx | 21 + .../src/renderer/components/ui/label.tsx | 27 + .../renderer/components/ui/scroll-area.tsx | 21 + .../src/renderer/components/ui/separator.tsx | 28 + .../src/renderer/components/ui/skeleton.tsx | 13 + .../renderer/components/ui/streaming-text.tsx | 140 + .../src/renderer/components/ui/textarea.tsx | 18 + .../desktop/src/renderer/lib/accomplish.ts | 200 + .../desktop/src/renderer/lib/analytics.ts | 106 + .../desktop/src/renderer/lib/animations.ts | 80 + .../apps/desktop/src/renderer/lib/utils.ts | 6 + .../src/renderer/lib/waiting-detection.ts | 70 + .../apps/desktop/src/renderer/main.tsx | 19 + .../desktop/src/renderer/pages/Execution.tsx | 1268 +++ .../desktop/src/renderer/pages/History.tsx | 15 + .../apps/desktop/src/renderer/pages/Home.tsx | 266 + .../desktop/src/renderer/stores/taskStore.ts | 502 + .../desktop/src/renderer/styles/globals.css | 142 + .../apps/desktop/src/vite-env.d.ts | 1 + .../apps/desktop/tailwind.config.ts | 145 + .../apps/desktop/tsconfig.json | 45 + .../apps/desktop/vite.config.ts | 70 + .../apps/desktop/vitest.config.ts | 81 + .../apps/desktop/vitest.integration.config.ts | 34 + .../apps/desktop/vitest.unit.config.ts | 33 + openwork-memos-integration/docs/banner.svg | 34 + .../2026-01-17-safe-file-deletion-impl.md | 745 ++ .../docs/video-thumbnail.png | Bin 0 -> 4605469 bytes openwork-memos-integration/package.json | 30 + .../packages/shared/package.json | 18 + .../packages/shared/src/index.ts | 1 + .../packages/shared/src/types/auth.ts | 60 + .../packages/shared/src/types/index.ts | 6 + .../packages/shared/src/types/opencode.ts | 162 + .../packages/shared/src/types/permission.ts | 55 + .../packages/shared/src/types/provider.ts | 275 + .../shared/src/types/providerSettings.ts | 125 + .../packages/shared/src/types/task.ts | 86 + .../packages/shared/tsconfig.json | 25 + openwork-memos-integration/pnpm-lock.yaml | 9315 +++++++++++++++++ .../pnpm-workspace.yaml | 3 + 254 files changed, 61862 insertions(+) create mode 100644 openwork-memos-integration/CLAUDE.md create mode 100644 openwork-memos-integration/CONTRIBUTING.md create mode 100644 openwork-memos-integration/LICENSE create mode 100644 openwork-memos-integration/README.md create mode 100644 openwork-memos-integration/SECURITY.md create mode 100644 openwork-memos-integration/apps/desktop/.eslintrc.json create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx create mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/setup.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts create mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts create mode 100755 openwork-memos-integration/apps/desktop/clean_dmg_install.sh create mode 100644 openwork-memos-integration/apps/desktop/e2e/README.md create mode 100644 openwork-memos-integration/apps/desktop/e2e/config/index.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile create mode 100644 openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml create mode 100644 openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/index.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/playwright.config.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/utils/index.ts create mode 100644 openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts create mode 100644 openwork-memos-integration/apps/desktop/index.html create mode 100644 openwork-memos-integration/apps/desktop/package.json create mode 100644 openwork-memos-integration/apps/desktop/postcss.config.js create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg create mode 100755 openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg create mode 100644 openwork-memos-integration/apps/desktop/public/assets/logo-1.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/logo.png create mode 100755 openwork-memos-integration/apps/desktop/public/assets/openwork-icon.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/ai-image-wizard.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/automated-reminders.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/batch-file-renaming.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/bilingual-output.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/calendar-events.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/calendar-prep-notes.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/career-document.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/clean-data-output.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/competitor-pricing-deck.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/course-announcement.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/custom-web-tool.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/document-translation.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/event-calendar-builder.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/export-to-table.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/inbox-promo-cleanup.png create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/job-application-automation.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/landing-page-copy.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/localize-content.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/notion-api-audit.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/organize-data.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/personal-website.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/pitch-deck.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/polish-writing.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/portfolio-presentation.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/prod-broken-links.png create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/professional-emails.webp create mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/professional-headshot.webp create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/staging-vs-prod-visual.png create mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/stock-portfolio-alerts.png create mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Black.ttf create mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Bold.ttf create mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Light.ttf create mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Medium.ttf create mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Regular.ttf create mode 100644 openwork-memos-integration/apps/desktop/resources/entitlements.mac.plist create mode 100644 openwork-memos-integration/apps/desktop/resources/icon.png create mode 100755 openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh create mode 100755 openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh create mode 100755 openwork-memos-integration/apps/desktop/run_prod.sh create mode 100755 openwork-memos-integration/apps/desktop/run_staging.sh create mode 100644 openwork-memos-integration/apps/desktop/scripts/after-pack.cjs create mode 100644 openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs create mode 100644 openwork-memos-integration/apps/desktop/scripts/package.cjs create mode 100644 openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs create mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md create mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/package-lock.json create mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json create mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/bun.lock create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/package-lock.json create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/package.json create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts create mode 100755 openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json create mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/package-lock.json create mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/package.json create mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts create mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json create mode 100644 openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md create mode 100644 openwork-memos-integration/apps/desktop/src/main/config.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/index.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/permission-api.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/services/memory.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts create mode 100644 openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts create mode 100644 openwork-memos-integration/apps/desktop/src/preload/index.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/App.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/ConversationListItem.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/Header.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/SettingsDialog.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/Sidebar.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderCard.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderGrid.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderSettingsPanel.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/hooks/useProviderSettings.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/BedrockProviderForm.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/ClassicProviderForm.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/LiteLLMProviderForm.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OllamaProviderForm.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OpenRouterProviderForm.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/index.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ApiKeyInput.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectButton.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectedControls.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectionStatus.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/FormError.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ModelSelector.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ProviderFormHeader.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/RegionSelector.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/index.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/avatar.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/badge.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/button.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/card.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/dialog.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/dropdown-menu.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/input.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/label.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/scroll-area.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/separator.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/skeleton.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/streaming-text.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/textarea.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/accomplish.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/analytics.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/animations.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/utils.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/waiting-detection.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/main.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/pages/Execution.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/pages/History.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/pages/Home.tsx create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/stores/taskStore.ts create mode 100644 openwork-memos-integration/apps/desktop/src/renderer/styles/globals.css create mode 100644 openwork-memos-integration/apps/desktop/src/vite-env.d.ts create mode 100644 openwork-memos-integration/apps/desktop/tailwind.config.ts create mode 100644 openwork-memos-integration/apps/desktop/tsconfig.json create mode 100644 openwork-memos-integration/apps/desktop/vite.config.ts create mode 100644 openwork-memos-integration/apps/desktop/vitest.config.ts create mode 100644 openwork-memos-integration/apps/desktop/vitest.integration.config.ts create mode 100644 openwork-memos-integration/apps/desktop/vitest.unit.config.ts create mode 100644 openwork-memos-integration/docs/banner.svg create mode 100644 openwork-memos-integration/docs/plans/2026-01-17-safe-file-deletion-impl.md create mode 100644 openwork-memos-integration/docs/video-thumbnail.png create mode 100644 openwork-memos-integration/package.json create mode 100644 openwork-memos-integration/packages/shared/package.json create mode 100644 openwork-memos-integration/packages/shared/src/index.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/auth.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/index.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/opencode.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/permission.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/provider.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/providerSettings.ts create mode 100644 openwork-memos-integration/packages/shared/src/types/task.ts create mode 100644 openwork-memos-integration/packages/shared/tsconfig.json create mode 100644 openwork-memos-integration/pnpm-lock.yaml create mode 100644 openwork-memos-integration/pnpm-workspace.yaml diff --git a/openwork-memos-integration/CLAUDE.md b/openwork-memos-integration/CLAUDE.md new file mode 100644 index 000000000..74a9707da --- /dev/null +++ b/openwork-memos-integration/CLAUDE.md @@ -0,0 +1,162 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Openwork is a standalone desktop automation assistant built with Electron. The app hosts a local React UI (bundled via Vite), communicating with the main process through `contextBridge` IPC. The main process spawns the OpenCode CLI (via `node-pty`) to execute user tasks. Users provide their own API key (Anthropic, OpenAI, Google, or xAI) on first launch, stored securely in the OS keychain. + +## Common Commands + +```bash +pnpm dev # Run desktop app in dev mode (Vite + Electron) +pnpm dev:clean # Dev mode with CLEAN_START=1 (clears stored data) +pnpm build # Build all workspaces +pnpm build:desktop # Build desktop app only +pnpm lint # TypeScript checks +pnpm typecheck # Type validation +pnpm clean # Clean build outputs and node_modules +pnpm -F @accomplish/desktop test:e2e # Playwright E2E tests +pnpm -F @accomplish/desktop test:e2e:ui # E2E with Playwright UI +pnpm -F @accomplish/desktop test:e2e:debug # E2E in debug mode +``` + +## Architecture + +### Monorepo Layout +``` +apps/desktop/ # Electron app (main/preload/renderer) +packages/shared/ # Shared TypeScript types +``` + +### Desktop App Structure (`apps/desktop/src/`) + +**Main Process** (`main/`): +- `index.ts` - Electron bootstrap, single-instance enforcement, `accomplish://` protocol handler +- `ipc/handlers.ts` - IPC handlers for task lifecycle, settings, onboarding, API keys +- `opencode/adapter.ts` - OpenCode CLI wrapper using `node-pty`, streams output and handles permissions +- `store/secureStorage.ts` - API key storage via `keytar` (OS keychain) +- `store/appSettings.ts` - App settings via `electron-store` (debug mode, onboarding state) +- `store/taskHistory.ts` - Task history persistence + +**Preload** (`preload/index.ts`): +- Exposes `window.accomplish` API via `contextBridge` +- Provides typed IPC methods for task operations, settings, events + +**Renderer** (`renderer/`): +- `main.tsx` - React entry with HashRouter +- `App.tsx` - Main routing + onboarding gate +- `pages/` - Home, Execution, History, Settings pages +- `stores/taskStore.ts` - Zustand store for task/UI state +- `lib/accomplish.ts` - Typed wrapper for the IPC API + +### IPC Communication Flow +``` +Renderer (React) + ↓ window.accomplish.* calls +Preload (contextBridge) + ↓ ipcRenderer.invoke +Main Process + ↓ Native APIs (keytar, node-pty, electron-store) + ↑ IPC events +Preload + ↑ ipcRenderer.on callbacks +Renderer +``` + +### Key Dependencies +- `node-pty` - PTY for OpenCode CLI spawning +- `keytar` - Secure API key storage (OS keychain) +- `electron-store` - Local settings/preferences +- `opencode-ai` - Bundled OpenCode CLI (multi-provider: Anthropic, OpenAI, Google, xAI) + +## Code Conventions + +- TypeScript everywhere (no JS for app logic) +- Use `pnpm -F @accomplish/desktop ...` for desktop-specific commands +- Shared types go in `packages/shared/src/types/` +- Renderer state via Zustand store actions +- IPC handlers in `src/main/ipc/handlers.ts` must match `window.accomplish` API in preload + +### Image Assets in Renderer + +**IMPORTANT:** Always use ES module imports for images in the renderer, never absolute paths. + +```typescript +// CORRECT - Use ES imports +import logoImage from '/assets/logo.png'; +Logo + +// WRONG - Absolute paths break in packaged app +Logo +``` + +**Why:** In development, Vite serves `/assets/...` from the public folder. But in the packaged Electron app, the renderer loads via `file://` protocol, and absolute paths like `/assets/logo.png` resolve to the filesystem root instead of the app bundle. ES imports are processed by Vite to use `import.meta.url`, which works correctly in both environments. + +Static assets go in `apps/desktop/public/assets/`. + +## Environment Variables + +- `CLEAN_START=1` - Clear all stored data on app start +- `E2E_SKIP_AUTH=1` - Skip onboarding flow (for testing) + +## Testing + +- E2E tests: `pnpm -F @accomplish/desktop test:e2e` +- Tests use Playwright with serial execution (Electron requirement) +- Test config: `apps/desktop/playwright.config.ts` + +## Bundled Node.js + +The packaged app bundles standalone Node.js v20.18.1 binaries to ensure MCP servers work on machines without Node.js installed. + +### Key Files +- `src/main/utils/bundled-node.ts` - Utility to get bundled node/npm/npx paths +- `scripts/download-nodejs.cjs` - Downloads Node.js binaries for all platforms +- `scripts/after-pack.cjs` - Copies correct binary into app bundle during build + +### CRITICAL: Spawning npx/node in Main Process + +**IMPORTANT:** When spawning `npx` or `node` in the main process, you MUST add the bundled Node.js bin directory to PATH. This is because `npx` uses a `#!/usr/bin/env node` shebang which looks for `node` in PATH. + +```typescript +import { spawn } from 'child_process'; +import { getNpxPath, getBundledNodePaths } from '../utils/bundled-node'; + +// Get bundled paths +const npxPath = getNpxPath(); +const bundledPaths = getBundledNodePaths(); + +// Build environment with bundled node in PATH +let spawnEnv: NodeJS.ProcessEnv = { ...process.env }; +if (bundledPaths) { + const delimiter = process.platform === 'win32' ? ';' : ':'; + spawnEnv.PATH = `${bundledPaths.binDir}${delimiter}${process.env.PATH || ''}`; +} + +// Spawn with the modified environment +spawn(npxPath, ['-y', 'some-package@latest'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: spawnEnv, +}); +``` + +**Why:** Without adding `bundledPaths.binDir` to PATH, the spawned process will fail with exit code 127 ("node not found") on machines that don't have Node.js installed system-wide. + +### For MCP Server Configs + +When generating MCP server configurations, pass `NODE_BIN_PATH` in the environment so spawned servers can add it to their PATH: + +```typescript +environment: { + NODE_BIN_PATH: bundledPaths?.binDir || '', +} +``` + +## Key Behaviors + +- Single-instance enforcement - second instance focuses existing window +- API keys stored in OS keychain (macOS Keychain, Windows Credential Vault, Linux Secret Service) +- API key validation via test request to respective provider API +- OpenCode CLI permissions are bridged to UI via IPC `permission:request` / `permission:respond` +- Task output streams through `task:update` and `task:progress` IPC events diff --git a/openwork-memos-integration/CONTRIBUTING.md b/openwork-memos-integration/CONTRIBUTING.md new file mode 100644 index 000000000..b5dcb6dd1 --- /dev/null +++ b/openwork-memos-integration/CONTRIBUTING.md @@ -0,0 +1,60 @@ +# Contributing to Openwork + +Thank you for your interest in contributing to Openwork! This document provides guidelines and instructions for contributing. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/openwork.git` +3. Install dependencies: `pnpm install` +4. Create a branch: `git checkout -b feature/your-feature-name` + +## Development + +```bash +pnpm dev # Run the desktop app in development mode +pnpm build # Build all workspaces +pnpm typecheck # Run TypeScript checks +pnpm lint # Run linting +``` + +## Code Style + +- TypeScript for all application code +- Follow existing patterns in the codebase +- Use meaningful variable and function names +- Keep functions focused and small + +## Pull Request Process + +1. Ensure your code builds without errors (`pnpm build`) +2. Run type checking (`pnpm typecheck`) +3. Update documentation if needed +4. Write a clear PR description explaining: + - What the change does + - Why it's needed + - How to test it + +## Commit Messages + +Use clear, descriptive commit messages: +- `feat: add dark mode support` +- `fix: resolve crash on startup` +- `docs: update README with new instructions` +- `refactor: simplify task queue logic` + +## Reporting Issues + +When reporting issues, please include: +- OS and version +- Steps to reproduce +- Expected vs actual behavior +- Any error messages or logs + +## Security + +If you discover a security vulnerability, please see [SECURITY.md](SECURITY.md) for responsible disclosure guidelines. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/openwork-memos-integration/LICENSE b/openwork-memos-integration/LICENSE new file mode 100644 index 000000000..e548c0a6f --- /dev/null +++ b/openwork-memos-integration/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Accomplish Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openwork-memos-integration/README.md b/openwork-memos-integration/README.md new file mode 100644 index 000000000..132d7455a --- /dev/null +++ b/openwork-memos-integration/README.md @@ -0,0 +1,315 @@ +

+ Openwork - Open source AI desktop agent that automates file management, document creation, and browser tasks with your own AI API keys +

+ +

+ MIT License + GitHub Stars + GitHub Issues + Last Commit + Download for macOS +

+ +# Openwork™ - Open Source AI Desktop Agent + +Openwork is an open source AI desktop agent that automates file management, document creation, and browser tasks locally on your machine. Bring your own API keys (OpenAI, Anthropic, Google, xAI) or run local models via Ollama. + +

+ Runs locally on your machine. Bring your own API keys or local models. MIT licensed. +

+ +

+ Download Openwork for Mac (Apple Silicon) + · + Openwork website + · + Openwork blog + · + Openwork releases +

+ +
+ +--- + +
+ +## What makes it different + + + + + + + + + + +
+ +### 🖥️ It runs locally + +
+ +- Your files stay on your machine +- You decide which folders it can touch +- Nothing gets sent to Openwork (or anyone else) + +
+ +
+ +### 🔑 You bring your own AI + +
+ +- Use your own API key (OpenAI, Anthropic, etc.) +- Or run with [Ollama](https://ollama.com) (no API key needed) +- No subscription, no upsell +- It's a tool—not a service + +
+ +
+ +### 📖 It's open source + +
+ +- Every line of code is on GitHub +- MIT licensed +- Change it, fork it, break it, fix it + +
+ +
+ +### ⚡ It acts, not just chats + +
+ +- File management +- Document creation +- Custom automations +- Skill learning + +
+ +
+ +
+ +--- + +
+ +## What it actually does + +| | | | +|:--|:--|:--| +| **📁 File Management** | **✍️ Document Writing** | **🔗 Tool Connections** | +| Sort, rename, and move files based on content or rules you give it | Prompt it to write, summarize, or rewrite documents | Works with Notion, Google Drive, Dropbox, and more (through local APIs) | +| | | | +| **⚙️ Custom Skills** | **🛡️ Full Control** | | +| Define repeatable workflows, save them as skills | You approve every action. You can see logs. You can stop it anytime. | | + +
+ +## Use cases + +- Clean up messy folders by project, file type, or date +- Draft, summarize, and rewrite docs, reports, and meeting notes +- Automate browser workflows like research and form entry +- Generate weekly updates from files and notes +- Prepare meeting materials from docs and calendars + +
+ +## Memory (MemOS) + +Openwork can connect to MemOS to provide long-term memory. When a MemOS API key is set, relevant memories are injected into the system prompt and new memories are saved after tasks finish. Learn more in the MemOS docs: https://memos-docs.openmem.net/ + +
+ +## Supported models and providers + +- OpenAI +- Anthropic +- Google +- xAI +- Ollama (local models) + +
+ +## Privacy and local-first + +Openwork runs locally on your machine. Your files stay on your device, and you choose which folders it can access. + +
+ +## System requirements + +- macOS (Apple Silicon) +- Windows support coming soon + +
+ +--- + +
+ +## How to use it + +> **Takes 2 minutes to set up.** + +| Step | Action | Details | +|:----:|--------|---------| +| **1** | **Install the App** | Download the DMG and drag it into Applications | +| **2** | **Connect Your AI** | Use your own OpenAI or Anthropic API key, or Ollama. No subscriptions. | +| **3** | **Give It Access** | Choose which folders it can see. You stay in control. | +| **4** | **Start Working** | Ask it to summarize a doc, clean a folder, or create a report. You approve everything. | + +
+ +
+ +[**Download for Mac (Apple Silicon)**](https://downloads.openwork.me/downloads/0.2.1/macos/Openwork-0.2.1-mac-arm64.dmg) + +
+ +
+ +--- + +
+ +## Screenshots and Demo + +A quick look at Openwork on macOS, plus a short demo video. + +

+ + Openwork demo - AI agent automating file management and browser tasks + +

+ +

+ Watch the demo → +

+ +
+ +## FAQ + +**Does Openwork run locally?** +Yes. Openwork runs locally on your machine and you control which folders it can access. + +**Do I need an API key?** +You can use your own API keys (OpenAI, Anthropic, Google, xAI) or run local models via Ollama. + +**Is Openwork free?** +Yes. Openwork is open source and MIT licensed. + +**Which platforms are supported?** +macOS (Apple Silicon) is available now. Windows support is coming soon. + +
+ +--- + +
+ +## Development + +```bash +pnpm install +pnpm dev +``` + +That's it. + +
+Prerequisites + +- Node.js 20+ +- pnpm 9+ + +
+ +
+All Commands + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Run desktop app in dev mode | +| `pnpm dev:clean` | Dev mode with clean start | +| `pnpm build` | Build all workspaces | +| `pnpm build:desktop` | Build desktop app only | +| `pnpm lint` | TypeScript checks | +| `pnpm typecheck` | Type validation | +| `pnpm -F @accomplish/desktop test:e2e` | Playwright E2E tests | + +
+ +
+Environment Variables + +| Variable | Description | +|----------|-------------| +| `CLEAN_START=1` | Clear all stored data on app start | +| `E2E_SKIP_AUTH=1` | Skip onboarding flow (for testing) | + +
+ +
+Architecture + +``` +apps/ + desktop/ # Electron app (main + preload + renderer) +packages/ + shared/ # Shared TypeScript types +``` + +The desktop app uses Electron with a React UI bundled via Vite. The main process spawns [OpenCode](https://github.com/sst/opencode) CLI using `node-pty` to execute tasks. API keys are stored securely in the OS keychain. + +See [CLAUDE.md](CLAUDE.md) for detailed architecture documentation. + +
+ +
+ +--- + +
+ +## Contributing + +Contributions welcome! Feel free to open a PR. + +```bash +# Fork → Clone → Branch → Commit → Push → PR +git checkout -b feature/amazing-feature +git commit -m 'Add amazing feature' +git push origin feature/amazing-feature +``` + +
+ +--- + +
+ +
+ +**[Openwork website](https://www.openwork.me/)** · **[Openwork blog](https://www.openwork.me/blog/)** · **[Openwork releases](https://github.com/accomplish-ai/openwork/releases)** · **[Issues](https://github.com/accomplish-ai/openwork/issues)** · **[Twitter](https://x.com/openwork_ai)** + +
+ +MIT License · Built by [Openwork](https://www.openwork.me) + +
+ +**Keywords:** AI agent, AI desktop agent, desktop automation, file management, document creation, browser automation, local-first, macOS, privacy-first, open source, Electron, computer use, AI assistant, workflow automation, OpenAI, Anthropic, Google, xAI, Claude, GPT-4, Ollama + +
diff --git a/openwork-memos-integration/SECURITY.md b/openwork-memos-integration/SECURITY.md new file mode 100644 index 000000000..16117e532 --- /dev/null +++ b/openwork-memos-integration/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 0.1.x | :white_check_mark: | + +## Reporting a Vulnerability + +We take security seriously. If you discover a security vulnerability, please report it responsibly. + +### How to Report + +1. **Do not** open a public GitHub issue for security vulnerabilities +2. Email security concerns to the maintainers (see GitHub profile) +3. Include: + - Description of the vulnerability + - Steps to reproduce + - Potential impact + - Any suggested fixes (optional) + +### What to Expect + +- Acknowledgment within 48 hours +- Regular updates on progress +- Credit in release notes (if desired) + +### Scope + +Security issues we're interested in: +- Remote code execution +- Local privilege escalation +- Data exposure +- Authentication/authorization bypasses +- IPC security issues + +Out of scope: +- Denial of service +- Social engineering +- Issues requiring physical access + +## Security Best Practices + +When using Openwork: +- Keep the application updated +- Only grant file permissions when necessary +- Review task outputs before approving sensitive operations +- Use API keys with minimal required permissions diff --git a/openwork-memos-integration/apps/desktop/.eslintrc.json b/openwork-memos-integration/apps/desktop/.eslintrc.json new file mode 100644 index 000000000..d655265ef --- /dev/null +++ b/openwork-memos-integration/apps/desktop/.eslintrc.json @@ -0,0 +1,39 @@ +{ + "root": true, + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "settings": { + "react": { + "version": "detect" + } + }, + "plugins": [ + "@typescript-eslint", + "react", + "react-hooks" + ], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended" + ], + "ignorePatterns": [ + "dist", + "dist-electron", + "release", + "node_modules" + ], + "rules": { + "react/react-in-jsx-scope": "off", + "react/prop-types": "off" + } +} diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts new file mode 100644 index 000000000..303dab022 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts @@ -0,0 +1,369 @@ +/** + * Integration tests for appSettings store + * Tests real electron-store interactions with temporary directories + * @module __tests__/integration/main/appSettings.integration.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create a unique temp directory for each test run +let tempDir: string; +let originalCwd: string; + +describe('appSettings Integration', () => { + beforeEach(async () => { + // Create a unique temp directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'appSettings-test-')); + originalCwd = process.cwd(); + + // Reset module cache first + vi.resetModules(); + + // Use doMock (not hoisted) so tempDir is captured with current value + vi.doMock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return tempDir; + } + return `/mock/path/${name}`; + }, + getVersion: () => '0.1.0', + getName: () => 'Accomplish', + isPackaged: false, + }, + })); + }); + + afterEach(() => { + // Clean up temp directory + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + process.chdir(originalCwd); + }); + + describe('debugMode', () => { + it('should return false as default value for debugMode', async () => { + // Arrange + const { getDebugMode, clearAppSettings } = await import('@main/store/appSettings'); + clearAppSettings(); // Ensure fresh state + + // Act + const result = getDebugMode(); + + // Assert + expect(result).toBe(false); + }); + + it('should persist debugMode after setting to true', async () => { + // Arrange + const { getDebugMode, setDebugMode } = await import('@main/store/appSettings'); + + // Act + setDebugMode(true); + const result = getDebugMode(); + + // Assert + expect(result).toBe(true); + }); + + it('should persist debugMode after setting to false', async () => { + // Arrange + const { getDebugMode, setDebugMode } = await import('@main/store/appSettings'); + + // Act - set to true first, then false + setDebugMode(true); + setDebugMode(false); + const result = getDebugMode(); + + // Assert + expect(result).toBe(false); + }); + + it('should round-trip debugMode value correctly', async () => { + // Arrange + const { getDebugMode, setDebugMode } = await import('@main/store/appSettings'); + + // Act + setDebugMode(true); + const afterTrue = getDebugMode(); + setDebugMode(false); + const afterFalse = getDebugMode(); + setDebugMode(true); + const afterTrueAgain = getDebugMode(); + + // Assert + expect(afterTrue).toBe(true); + expect(afterFalse).toBe(false); + expect(afterTrueAgain).toBe(true); + }); + }); + + describe('onboardingComplete', () => { + it('should return false as default value for onboardingComplete', async () => { + // Arrange + const { getOnboardingComplete, clearAppSettings } = await import('@main/store/appSettings'); + clearAppSettings(); // Ensure fresh state + + // Act + const result = getOnboardingComplete(); + + // Assert + expect(result).toBe(false); + }); + + it('should persist onboardingComplete after setting to true', async () => { + // Arrange + const { getOnboardingComplete, setOnboardingComplete } = await import('@main/store/appSettings'); + + // Act + setOnboardingComplete(true); + const result = getOnboardingComplete(); + + // Assert + expect(result).toBe(true); + }); + + it('should round-trip onboardingComplete value correctly', async () => { + // Arrange + const { getOnboardingComplete, setOnboardingComplete } = await import('@main/store/appSettings'); + + // Act + setOnboardingComplete(true); + const afterTrue = getOnboardingComplete(); + setOnboardingComplete(false); + const afterFalse = getOnboardingComplete(); + + // Assert + expect(afterTrue).toBe(true); + expect(afterFalse).toBe(false); + }); + }); + + describe('selectedModel', () => { + it('should return default model on fresh store', async () => { + // Arrange + const { getSelectedModel, clearAppSettings } = await import('@main/store/appSettings'); + clearAppSettings(); // Ensure fresh state + + // Act + const result = getSelectedModel(); + + // Assert + expect(result).toEqual({ + provider: 'anthropic', + model: 'anthropic/claude-opus-4-5', + }); + }); + + it('should persist selectedModel after setting new value', async () => { + // Arrange + const { getSelectedModel, setSelectedModel } = await import('@main/store/appSettings'); + const newModel = { provider: 'openai', model: 'gpt-4' }; + + // Act + setSelectedModel(newModel); + const result = getSelectedModel(); + + // Assert + expect(result).toEqual(newModel); + }); + + it('should round-trip different model values correctly', async () => { + // Arrange + const { getSelectedModel, setSelectedModel } = await import('@main/store/appSettings'); + const model1 = { provider: 'anthropic', model: 'claude-3-opus' }; + const model2 = { provider: 'google', model: 'gemini-pro' }; + const model3 = { provider: 'xai', model: 'grok-4' }; + + // Act & Assert + setSelectedModel(model1); + expect(getSelectedModel()).toEqual(model1); + + setSelectedModel(model2); + expect(getSelectedModel()).toEqual(model2); + + setSelectedModel(model3); + expect(getSelectedModel()).toEqual(model3); + }); + }); + + describe('getAppSettings', () => { + it('should return all default settings on fresh store', async () => { + // Arrange + const { getAppSettings, clearAppSettings } = await import('@main/store/appSettings'); + clearAppSettings(); // Ensure fresh state + + // Act + const result = getAppSettings(); + + // Assert + expect(result).toEqual({ + debugMode: false, + onboardingComplete: false, + ollamaConfig: null, + litellmConfig: null, + selectedModel: { + provider: 'anthropic', + model: 'anthropic/claude-opus-4-5', + }, + }); + }); + + it('should return all settings after modifications', async () => { + // Arrange + const { getAppSettings, setDebugMode, setOnboardingComplete, setSelectedModel, clearAppSettings } = await import('@main/store/appSettings'); + clearAppSettings(); // Start fresh + const customModel = { provider: 'openai', model: 'gpt-4-turbo' }; + + // Act + setDebugMode(true); + setOnboardingComplete(true); + setSelectedModel(customModel); + const result = getAppSettings(); + + // Assert + expect(result).toEqual({ + debugMode: true, + onboardingComplete: true, + ollamaConfig: null, + litellmConfig: null, + selectedModel: customModel, + }); + }); + + it('should reflect partial modifications correctly', async () => { + // Arrange + const { getAppSettings, setDebugMode, clearAppSettings } = await import('@main/store/appSettings'); + clearAppSettings(); // Start fresh + + // Act - only modify debugMode + setDebugMode(true); + const result = getAppSettings(); + + // Assert + expect(result.debugMode).toBe(true); + expect(result.onboardingComplete).toBe(false); + expect(result.selectedModel).toEqual({ + provider: 'anthropic', + model: 'anthropic/claude-opus-4-5', + }); + }); + }); + + describe('clearAppSettings', () => { + it('should reset all settings to defaults', async () => { + // Arrange + const { + getAppSettings, + clearAppSettings, + setDebugMode, + setOnboardingComplete, + setSelectedModel + } = await import('@main/store/appSettings'); + + // Set custom values + setDebugMode(true); + setOnboardingComplete(true); + setSelectedModel({ provider: 'openai', model: 'gpt-4' }); + + // Act + clearAppSettings(); + const result = getAppSettings(); + + // Assert + expect(result).toEqual({ + debugMode: false, + onboardingComplete: false, + ollamaConfig: null, + litellmConfig: null, + selectedModel: { + provider: 'anthropic', + model: 'anthropic/claude-opus-4-5', + }, + }); + }); + + it('should reset debugMode to default after clear', async () => { + // Arrange + const { getDebugMode, setDebugMode, clearAppSettings } = await import('@main/store/appSettings'); + + // Act + setDebugMode(true); + expect(getDebugMode()).toBe(true); + clearAppSettings(); + const result = getDebugMode(); + + // Assert + expect(result).toBe(false); + }); + + it('should reset onboardingComplete to default after clear', async () => { + // Arrange + const { getOnboardingComplete, setOnboardingComplete, clearAppSettings } = await import('@main/store/appSettings'); + + // Act + setOnboardingComplete(true); + expect(getOnboardingComplete()).toBe(true); + clearAppSettings(); + const result = getOnboardingComplete(); + + // Assert + expect(result).toBe(false); + }); + + it('should reset selectedModel to default after clear', async () => { + // Arrange + const { getSelectedModel, setSelectedModel, clearAppSettings } = await import('@main/store/appSettings'); + + // Act + setSelectedModel({ provider: 'openai', model: 'gpt-4' }); + expect(getSelectedModel()).toEqual({ provider: 'openai', model: 'gpt-4' }); + clearAppSettings(); + const result = getSelectedModel(); + + // Assert + expect(result).toEqual({ + provider: 'anthropic', + model: 'anthropic/claude-opus-4-5', + }); + }); + + it('should allow setting new values after clear', async () => { + // Arrange + const { getDebugMode, setDebugMode, clearAppSettings } = await import('@main/store/appSettings'); + + // Act + setDebugMode(true); + clearAppSettings(); + setDebugMode(true); + const result = getDebugMode(); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('persistence across module reloads', () => { + it('should persist values to disk and survive module reload', async () => { + // Arrange - first import and set values + const module1 = await import('@main/store/appSettings'); + module1.setDebugMode(true); + module1.setOnboardingComplete(true); + module1.setSelectedModel({ provider: 'google', model: 'gemini-ultra' }); + + // Act - reset modules and reimport + vi.resetModules(); + const module2 = await import('@main/store/appSettings'); + + // Assert - values should be persisted + expect(module2.getDebugMode()).toBe(true); + expect(module2.getOnboardingComplete()).toBe(true); + expect(module2.getSelectedModel()).toEqual({ provider: 'google', model: 'gemini-ultra' }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts new file mode 100644 index 000000000..7e0320988 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts @@ -0,0 +1,499 @@ +/** + * Integration tests for OpenCode CLI path resolution + * + * Tests the cli-path module which resolves paths to the OpenCode CLI binary + * in both development and packaged app modes. + * + * @module __tests__/integration/main/opencode/cli-path.integration.test + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import path from 'path'; + +// Mock electron module before importing the module under test +const mockApp = { + isPackaged: false, + getAppPath: vi.fn(() => '/mock/app/path'), +}; + +vi.mock('electron', () => ({ + app: mockApp, +})); + +// Mock fs module +const mockFs = { + existsSync: vi.fn(), + readdirSync: vi.fn(), + readFileSync: vi.fn(), +}; + +vi.mock('fs', () => ({ + default: mockFs, + existsSync: mockFs.existsSync, + readdirSync: mockFs.readdirSync, + readFileSync: mockFs.readFileSync, +})); + +// Mock child_process +const mockExecSync = vi.fn(); + +vi.mock('child_process', () => ({ + execSync: mockExecSync, +})); + +describe('OpenCode CLI Path Module', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset module state + vi.resetModules(); + // Reset packaged state + mockApp.isPackaged = false; + // Reset HOME environment variable + process.env.HOME = '/Users/testuser'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getOpenCodeCliPath()', () => { + describe('Development Mode', () => { + it('should return nvm OpenCode path when available', async () => { + // Arrange + mockApp.isPackaged = false; + const nvmVersionsDir = '/Users/testuser/.nvm/versions/node'; + const expectedPath = path.join(nvmVersionsDir, 'v20.10.0', 'bin', 'opencode'); + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === nvmVersionsDir) return true; + if (p === expectedPath) return true; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === nvmVersionsDir) return ['v20.10.0']; + return []; + }); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe(expectedPath); + expect(result.args).toEqual([]); + }); + + it('should return global npm OpenCode path when nvm not available', async () => { + // Arrange + mockApp.isPackaged = false; + const globalPath = '/usr/local/bin/opencode'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === globalPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe(globalPath); + expect(result.args).toEqual([]); + }); + + it('should return Homebrew OpenCode path on Apple Silicon', async () => { + // Arrange + mockApp.isPackaged = false; + const homebrewPath = '/opt/homebrew/bin/opencode'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === homebrewPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe(homebrewPath); + expect(result.args).toEqual([]); + }); + + it('should return bundled CLI path in node_modules when global not found', async () => { + // Arrange + mockApp.isPackaged = false; + const appPath = '/mock/app/path'; + const bundledPath = path.join(appPath, 'node_modules', '.bin', 'opencode'); + + mockApp.getAppPath.mockReturnValue(appPath); + mockFs.existsSync.mockImplementation((p: string) => { + if (p === bundledPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe(bundledPath); + expect(result.args).toEqual([]); + }); + + it('should fallback to PATH-based opencode when no paths found', async () => { + // Arrange + mockApp.isPackaged = false; + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe('opencode'); + expect(result.args).toEqual([]); + }); + }); + + describe('Packaged Mode', () => { + it('should return unpacked asar path when packaged', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + const expectedPath = path.join( + resourcesPath, + 'app.asar.unpacked', + 'node_modules', + 'opencode-ai', + 'bin', + 'opencode' + ); + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === expectedPath) return true; + return false; + }); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe(expectedPath); + expect(result.args).toEqual([]); + }); + + it('should throw error when bundled CLI not found in packaged app', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Act & Assert + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + expect(() => getOpenCodeCliPath()).toThrow('OpenCode CLI not found at'); + }); + }); + }); + + describe('isOpenCodeBundled()', () => { + describe('Development Mode', () => { + it('should return true when nvm OpenCode is available', async () => { + // Arrange + mockApp.isPackaged = false; + const nvmVersionsDir = '/Users/testuser/.nvm/versions/node'; + const opencodePath = path.join(nvmVersionsDir, 'v20.10.0', 'bin', 'opencode'); + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === nvmVersionsDir) return true; + if (p === opencodePath) return true; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === nvmVersionsDir) return ['v20.10.0']; + return []; + }); + + // Act + const { isOpenCodeBundled } = await import('@main/opencode/cli-path'); + const result = isOpenCodeBundled(); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when bundled CLI exists in node_modules', async () => { + // Arrange + mockApp.isPackaged = false; + const appPath = '/mock/app/path'; + const bundledPath = path.join(appPath, 'node_modules', '.bin', 'opencode'); + + mockApp.getAppPath.mockReturnValue(appPath); + mockFs.existsSync.mockImplementation((p: string) => { + if (p === bundledPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + + // Act + const { isOpenCodeBundled } = await import('@main/opencode/cli-path'); + const result = isOpenCodeBundled(); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when opencode is available on PATH', async () => { + // Arrange + mockApp.isPackaged = false; + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('/usr/local/bin/opencode'); + + // Act + const { isOpenCodeBundled } = await import('@main/opencode/cli-path'); + const result = isOpenCodeBundled(); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when no CLI is found anywhere', async () => { + // Arrange + mockApp.isPackaged = false; + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + // Act + const { isOpenCodeBundled } = await import('@main/opencode/cli-path'); + const result = isOpenCodeBundled(); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('Packaged Mode', () => { + it('should return true when bundled CLI exists in unpacked asar', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + const cliPath = path.join( + resourcesPath, + 'app.asar.unpacked', + 'node_modules', + 'opencode-ai', + 'bin', + 'opencode' + ); + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === cliPath) return true; + return false; + }); + + // Act + const { isOpenCodeBundled } = await import('@main/opencode/cli-path'); + const result = isOpenCodeBundled(); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when bundled CLI missing in unpacked asar', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Act + const { isOpenCodeBundled } = await import('@main/opencode/cli-path'); + const result = isOpenCodeBundled(); + + // Assert + expect(result).toBe(false); + }); + }); + }); + + describe('getBundledOpenCodeVersion()', () => { + describe('Packaged Mode', () => { + it('should read version from package.json in unpacked asar', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + const packageJsonPath = path.join( + resourcesPath, + 'app.asar.unpacked', + 'node_modules', + 'opencode-ai', + 'package.json' + ); + + mockFs.existsSync.mockImplementation((p: string) => p === packageJsonPath); + mockFs.readFileSync.mockImplementation((p: string) => { + if (p === packageJsonPath) { + return JSON.stringify({ version: '1.2.3' }); + } + return ''; + }); + + // Act + const { getBundledOpenCodeVersion } = await import('@main/opencode/cli-path'); + const result = getBundledOpenCodeVersion(); + + // Assert + expect(result).toBe('1.2.3'); + }); + + it('should return null when package.json not found', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Act + const { getBundledOpenCodeVersion } = await import('@main/opencode/cli-path'); + const result = getBundledOpenCodeVersion(); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('Development Mode', () => { + it('should execute CLI with --version flag and parse output', async () => { + // Arrange + mockApp.isPackaged = false; + const appPath = '/mock/app/path'; + const bundledPath = path.join(appPath, 'node_modules', '.bin', 'opencode'); + + mockApp.getAppPath.mockReturnValue(appPath); + mockFs.existsSync.mockImplementation((p: string) => { + if (p === bundledPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('opencode 1.5.0\n'); + + // Act + const { getBundledOpenCodeVersion } = await import('@main/opencode/cli-path'); + const result = getBundledOpenCodeVersion(); + + // Assert + expect(result).toBe('1.5.0'); + }); + + it('should parse version from simple version string', async () => { + // Arrange + mockApp.isPackaged = false; + const appPath = '/mock/app/path'; + const bundledPath = path.join(appPath, 'node_modules', '.bin', 'opencode'); + + mockApp.getAppPath.mockReturnValue(appPath); + mockFs.existsSync.mockImplementation((p: string) => { + if (p === bundledPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('2.0.1'); + + // Act + const { getBundledOpenCodeVersion } = await import('@main/opencode/cli-path'); + const result = getBundledOpenCodeVersion(); + + // Assert + expect(result).toBe('2.0.1'); + }); + + it('should return null when version command fails', async () => { + // Arrange + mockApp.isPackaged = false; + const appPath = '/mock/app/path'; + const bundledPath = path.join(appPath, 'node_modules', '.bin', 'opencode'); + + mockApp.getAppPath.mockReturnValue(appPath); + mockFs.existsSync.mockImplementation((p: string) => { + if (p === bundledPath) return true; + return false; + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + // Act + const { getBundledOpenCodeVersion } = await import('@main/opencode/cli-path'); + const result = getBundledOpenCodeVersion(); + + // Assert + expect(result).toBeNull(); + }); + }); + }); + + describe('NVM Path Scanning', () => { + it('should scan multiple nvm versions and return first found', async () => { + // Arrange + mockApp.isPackaged = false; + const nvmVersionsDir = '/Users/testuser/.nvm/versions/node'; + const v18Path = path.join(nvmVersionsDir, 'v18.17.0', 'bin', 'opencode'); + const v20Path = path.join(nvmVersionsDir, 'v20.10.0', 'bin', 'opencode'); + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === nvmVersionsDir) return true; + if (p === v20Path) return true; + if (p === v18Path) return false; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === nvmVersionsDir) return ['v18.17.0', 'v20.10.0']; + return []; + }); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert + expect(result.command).toBe(v20Path); + }); + + it('should handle missing nvm directory gracefully', async () => { + // Arrange + mockApp.isPackaged = false; + process.env.HOME = '/Users/testuser'; + + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + + // Act + const { getOpenCodeCliPath } = await import('@main/opencode/cli-path'); + const result = getOpenCodeCliPath(); + + // Assert - should fallback to opencode on PATH + expect(result.command).toBe('opencode'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts new file mode 100644 index 000000000..e6636410e --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts @@ -0,0 +1,332 @@ +/** + * Integration tests for OpenCode config generator + * + * Tests the config-generator module which creates OpenCode configuration files + * with MCP servers, agent definitions, and system prompts. + * + * NOTE: This is a TRUE integration test. + * - Uses REAL filesystem operations with temp directories + * - Only mocks external dependencies (electron APIs) + * + * Mocked external services: + * - electron.app: Native Electron APIs (getPath, getAppPath, isPackaged) + * + * Real implementations used: + * - fs: Real filesystem operations in temp directories + * - path: Real path operations + * + * @module __tests__/integration/main/opencode/config-generator.integration.test + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +// Create temp directories for each test +let tempUserDataDir: string; +let tempAppDir: string; + +// Mock only the external electron module +const mockApp = { + isPackaged: false, + getAppPath: vi.fn(() => tempAppDir), + getPath: vi.fn((name: string) => { + if (name === 'userData') return tempUserDataDir; + return path.join(tempUserDataDir, name); + }), +}; + +vi.mock('electron', () => ({ + app: mockApp, +})); + +// Mock permission-api module (internal but exports constants we need) +vi.mock('@main/permission-api', () => ({ + PERMISSION_API_PORT: 9999, + QUESTION_API_PORT: 9227, +})); + +describe('OpenCode Config Generator Integration', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + originalEnv = { ...process.env }; + mockApp.isPackaged = false; + + // Create real temp directories for each test + tempUserDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-config-test-userData-')); + tempAppDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-config-test-app-')); + + // Create skills directory structure in temp app dir + const skillsDir = path.join(tempAppDir, 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.mkdirSync(path.join(skillsDir, 'file-permission', 'src'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'file-permission', 'src', 'index.ts'), '// mock file'); + + // Update mock to use temp directories + mockApp.getAppPath.mockReturnValue(tempAppDir); + mockApp.getPath.mockImplementation((name: string) => { + if (name === 'userData') return tempUserDataDir; + return path.join(tempUserDataDir, name); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.env = originalEnv; + + // Clean up temp directories + try { + fs.rmSync(tempUserDataDir, { recursive: true, force: true }); + fs.rmSync(tempAppDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getSkillsPath()', () => { + describe('Development Mode', () => { + it('should return skills path relative to app path in dev mode', async () => { + // Arrange + mockApp.isPackaged = false; + + // Act + const { getSkillsPath } = await import('@main/opencode/config-generator'); + const result = getSkillsPath(); + + // Assert + expect(result).toBe(path.join(tempAppDir, 'skills')); + }); + }); + + describe('Packaged Mode', () => { + it('should return skills path in resources folder when packaged', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = path.join(tempAppDir, 'Resources'); + fs.mkdirSync(resourcesPath, { recursive: true }); + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + // Act + const { getSkillsPath } = await import('@main/opencode/config-generator'); + const result = getSkillsPath(); + + // Assert + expect(result).toBe(path.join(resourcesPath, 'skills')); + }); + }); + }); + + describe('generateOpenCodeConfig()', () => { + it('should create config directory if it does not exist', async () => { + // Arrange - config dir does not exist initially + + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + await generateOpenCodeConfig(); + + // Assert - verify directory was created using real fs + const configDir = path.join(tempUserDataDir, 'opencode'); + expect(fs.existsSync(configDir)).toBe(true); + }); + + it('should not recreate directory if it already exists', async () => { + // Arrange - create config dir beforehand + const configDir = path.join(tempUserDataDir, 'opencode'); + fs.mkdirSync(configDir, { recursive: true }); + const statBefore = fs.statSync(configDir); + + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + await generateOpenCodeConfig(); + + // Assert - directory still exists, no error + expect(fs.existsSync(configDir)).toBe(true); + }); + + it('should write config file with correct structure', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert - read the real file + expect(fs.existsSync(configPath)).toBe(true); + const configContent = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(configContent); + + expect(config.$schema).toBe('https://opencode.ai/config.json'); + expect(config.default_agent).toBe('accomplish'); + expect(config.permission).toBe('allow'); + expect(config.enabled_providers).toContain('anthropic'); + expect(config.enabled_providers).toContain('openai'); + expect(config.enabled_providers).toContain('google'); + }); + + it('should include accomplish agent configuration', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const agent = config.agent['accomplish']; + + expect(agent).toBeDefined(); + expect(agent.description).toBe('Browser automation assistant using dev-browser'); + expect(agent.mode).toBe('primary'); + expect(typeof agent.prompt).toBe('string'); + expect(agent.prompt.length).toBeGreaterThan(0); + }); + + it('should include MCP server configuration for file-permission', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const filePermission = config.mcp['file-permission']; + + expect(filePermission).toBeDefined(); + expect(filePermission.type).toBe('local'); + expect(filePermission.enabled).toBe(true); + expect(filePermission.command[0]).toBe('npx'); + expect(filePermission.command[1]).toBe('tsx'); + expect(filePermission.environment.PERMISSION_API_PORT).toBe('9999'); + }); + + it('should inject skills path into system prompt', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const prompt = config.agent['accomplish'].prompt; + const skillsPath = path.join(tempAppDir, 'skills'); + + // Prompt should contain the actual skills path, not the template placeholder + expect(prompt).toContain(skillsPath); + expect(prompt).not.toContain('{{SKILLS_PATH}}'); + }); + + it('should set OPENCODE_CONFIG environment variable after generation', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + expect(process.env.OPENCODE_CONFIG).toBe(configPath); + expect(configPath).toBe(path.join(tempUserDataDir, 'opencode', 'opencode.json')); + }); + + it('should return the config file path', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const result = await generateOpenCodeConfig(); + + // Assert + expect(result).toBe(path.join(tempUserDataDir, 'opencode', 'opencode.json')); + expect(fs.existsSync(result)).toBe(true); + }); + }); + + describe('getOpenCodeConfigPath()', () => { + it('should return config path in userData directory', async () => { + // Act + const { getOpenCodeConfigPath } = await import('@main/opencode/config-generator'); + const result = getOpenCodeConfigPath(); + + // Assert + expect(result).toBe(path.join(tempUserDataDir, 'opencode', 'opencode.json')); + }); + }); + + describe('System Prompt Content', () => { + it('should include browser automation guidance', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const prompt = config.agent['accomplish'].prompt; + + expect(prompt).toContain('browser'); + expect(prompt.toLowerCase()).toContain('playwright'); + }); + + it('should include file permission rules', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const prompt = config.agent['accomplish'].prompt; + + expect(prompt).toContain('FILE PERMISSION WORKFLOW'); + expect(prompt).toContain('request_file_permission'); + }); + + it('should include user communication guidance', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const prompt = config.agent['accomplish'].prompt; + + expect(prompt).toContain('user-communication'); + expect(prompt).toContain('AskUserQuestion'); + }); + }); + + describe('ACCOMPLISH_AGENT_NAME Export', () => { + it('should export the agent name constant', async () => { + // Act + const { ACCOMPLISH_AGENT_NAME } = await import('@main/opencode/config-generator'); + + // Assert + expect(ACCOMPLISH_AGENT_NAME).toBe('accomplish'); + }); + }); + + describe('Config File Persistence', () => { + it('should overwrite existing config file on regeneration', async () => { + // Arrange - generate config first time + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const firstPath = await generateOpenCodeConfig(); + const firstContent = fs.readFileSync(firstPath, 'utf-8'); + + // Reset modules to re-run generator + vi.resetModules(); + + // Act - generate again + const { generateOpenCodeConfig: regenerate } = await import('@main/opencode/config-generator'); + const secondPath = await regenerate(); + const secondContent = fs.readFileSync(secondPath, 'utf-8'); + + // Assert - same path, same content structure + expect(firstPath).toBe(secondPath); + expect(JSON.parse(firstContent).$schema).toBe(JSON.parse(secondContent).$schema); + }); + + it('should create valid JSON that can be parsed', async () => { + // Act + const { generateOpenCodeConfig } = await import('@main/opencode/config-generator'); + const configPath = await generateOpenCodeConfig(); + + // Assert - should not throw when parsing + const content = fs.readFileSync(configPath, 'utf-8'); + expect(() => JSON.parse(content)).not.toThrow(); + + // Should be pretty-printed (contains newlines) + expect(content).toContain('\n'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts new file mode 100644 index 000000000..f78a0355d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts @@ -0,0 +1,120 @@ +/** + * Integration tests for Permission API + * + * Tests the REAL exported functions from permission-api module: + * - isFilePermissionRequest() - checks if request ID is a file permission + * - resolvePermission() - resolves a pending permission request + * - initPermissionApi() - initializes the API with window and task getter + * - startPermissionApiServer() - starts the HTTP server + * - PERMISSION_API_PORT - the port constant + * + * These tests mock only electron (external dependency) and test the real + * module behavior. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock electron before importing the module +vi.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: vi.fn(), + getFocusedWindow: vi.fn(), + getAllWindows: vi.fn(() => []), + }, + app: { + isPackaged: false, + getPath: vi.fn(() => '/tmp/test-app'), + }, +})); + +// Import the REAL module functions after mocking electron +import { + isFilePermissionRequest, + resolvePermission, + initPermissionApi, + startPermissionApiServer, + PERMISSION_API_PORT, +} from '@main/permission-api'; + +describe('Permission API Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('isFilePermissionRequest', () => { + it('should return true for IDs starting with filereq_', () => { + expect(isFilePermissionRequest('filereq_123')).toBe(true); + expect(isFilePermissionRequest('filereq_abc_def')).toBe(true); + expect(isFilePermissionRequest('filereq_1234567890_abcdefghi')).toBe(true); + expect(isFilePermissionRequest('filereq_')).toBe(true); + }); + + it('should return false for IDs not starting with filereq_', () => { + expect(isFilePermissionRequest('req_123')).toBe(false); + expect(isFilePermissionRequest('permission_abc')).toBe(false); + expect(isFilePermissionRequest('file_req_123')).toBe(false); + expect(isFilePermissionRequest('FILEREQ_123')).toBe(false); // case sensitive + expect(isFilePermissionRequest('')).toBe(false); + expect(isFilePermissionRequest('filereq')).toBe(false); // missing underscore + expect(isFilePermissionRequest('_filereq_123')).toBe(false); + }); + }); + + describe('resolvePermission', () => { + it('should return false for non-existent request ID', () => { + // The real function returns false when the request is not in pending + expect(resolvePermission('filereq_nonexistent', true)).toBe(false); + expect(resolvePermission('filereq_notpending', false)).toBe(false); + }); + + it('should return false when called multiple times with same ID', () => { + const requestId = 'filereq_double_resolve'; + // First call returns false (not pending) + expect(resolvePermission(requestId, true)).toBe(false); + // Second call also returns false (still not pending) + expect(resolvePermission(requestId, false)).toBe(false); + }); + }); + + describe('PERMISSION_API_PORT', () => { + it('should be exported with correct value', () => { + expect(PERMISSION_API_PORT).toBe(9226); + }); + }); + + describe('initPermissionApi', () => { + it('should accept window and task getter without throwing', () => { + const mockWindow = { + isDestroyed: () => false, + webContents: { + send: vi.fn(), + isDestroyed: () => false, + }, + } as unknown as import('electron').BrowserWindow; + const mockTaskGetter = () => 'task_123'; + + expect(() => initPermissionApi(mockWindow, mockTaskGetter)).not.toThrow(); + }); + + it('should be a function', () => { + expect(typeof initPermissionApi).toBe('function'); + }); + }); + + describe('startPermissionApiServer', () => { + it('should be a function', () => { + expect(typeof startPermissionApiServer).toBe('function'); + }); + + it('should return an HTTP server when called', () => { + const server = startPermissionApiServer(); + expect(server).toBeDefined(); + // Clean up - close the server + server?.close(); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts new file mode 100644 index 000000000..c5d57ce6c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts @@ -0,0 +1,519 @@ +/** + * Integration tests for secureStorage module + * Tests real electron-store interactions with encrypted API key storage + * @module __tests__/integration/main/secureStorage.integration.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create a unique temp directory for each test run +let tempDir: string; +let originalCwd: string; + +// Use a factory function that closes over tempDir +const getTempDir = () => tempDir; + +// Mock electron module to control userData path +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return getTempDir(); + } + return `/mock/path/${name}`; + }, + getVersion: () => '0.1.0', + getName: () => 'Accomplish', + isPackaged: false, + }, +})); + +describe('secureStorage Integration', () => { + beforeEach(async () => { + // Create a unique temp directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'secureStorage-test-')); + originalCwd = process.cwd(); + + // Reset module cache to get fresh store instances + vi.resetModules(); + }); + + afterEach(async () => { + // Clear secure storage + try { + const { clearSecureStorage } = await import('@main/store/secureStorage'); + clearSecureStorage(); + } catch { + // Module may not be loaded + } + + // Clean up temp directory + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + process.chdir(originalCwd); + }); + + describe('storeApiKey and getApiKey', () => { + it('should store and retrieve an API key', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + const testKey = 'sk-test-anthropic-key-12345'; + + // Act + storeApiKey('anthropic', testKey); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe(testKey); + }); + + it('should return null for non-existent provider', async () => { + // Arrange + const { getApiKey } = await import('@main/store/secureStorage'); + + // Act + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBeNull(); + }); + + it('should encrypt the API key in storage', async () => { + // Arrange + const { storeApiKey } = await import('@main/store/secureStorage'); + const testKey = 'sk-test-visible-key'; + + // Act + storeApiKey('anthropic', testKey); + + // Assert - check that the raw file does not contain the key in plain text + const files = fs.readdirSync(tempDir); + const storeFile = files.find(f => f.includes('secure-storage')); + if (storeFile) { + const content = fs.readFileSync(path.join(tempDir, storeFile), 'utf-8'); + expect(content).not.toContain(testKey); + } + }); + + it('should overwrite existing key for same provider', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + const firstKey = 'sk-first-key'; + const secondKey = 'sk-second-key'; + + // Act + storeApiKey('anthropic', firstKey); + storeApiKey('anthropic', secondKey); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe(secondKey); + }); + + it('should handle special characters in API key', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + const testKey = 'sk-test_key+with/special=chars!@#$%^&*()'; + + // Act + storeApiKey('anthropic', testKey); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe(testKey); + }); + + it('should handle very long API keys', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + const testKey = 'sk-' + 'a'.repeat(500); + + // Act + storeApiKey('anthropic', testKey); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe(testKey); + }); + + it('should handle empty string as API key', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + + // Act + storeApiKey('anthropic', ''); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe(''); + }); + }); + + describe('multiple providers', () => { + it('should store API keys for different providers independently', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + + // Act + storeApiKey('anthropic', 'anthropic-key-123'); + storeApiKey('openai', 'openai-key-456'); + storeApiKey('google', 'google-key-789'); + storeApiKey('custom', 'custom-key-xyz'); + + // Assert + expect(getApiKey('anthropic')).toBe('anthropic-key-123'); + expect(getApiKey('openai')).toBe('openai-key-456'); + expect(getApiKey('google')).toBe('google-key-789'); + expect(getApiKey('custom')).toBe('custom-key-xyz'); + }); + + it('should not affect other providers when updating one', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-original'); + storeApiKey('openai', 'openai-original'); + + // Act + storeApiKey('anthropic', 'anthropic-updated'); + + // Assert + expect(getApiKey('anthropic')).toBe('anthropic-updated'); + expect(getApiKey('openai')).toBe('openai-original'); + }); + }); + + describe('deleteApiKey', () => { + it('should remove only the target provider key', async () => { + // Arrange + const { storeApiKey, getApiKey, deleteApiKey } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-key'); + storeApiKey('openai', 'openai-key'); + + // Act + const deleted = deleteApiKey('anthropic'); + + // Assert + expect(deleted).toBe(true); + expect(getApiKey('anthropic')).toBeNull(); + expect(getApiKey('openai')).toBe('openai-key'); + }); + + it('should return false when deleting non-existent key', async () => { + // Arrange + const { deleteApiKey } = await import('@main/store/secureStorage'); + + // Act + const result = deleteApiKey('anthropic'); + + // Assert + expect(result).toBe(false); + }); + + it('should allow re-storing after deletion', async () => { + // Arrange + const { storeApiKey, getApiKey, deleteApiKey } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'original-key'); + deleteApiKey('anthropic'); + + // Act + storeApiKey('anthropic', 'new-key'); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe('new-key'); + }); + }); + + describe('getAllApiKeys', () => { + it('should return all null for empty store', async () => { + // Arrange + const { getAllApiKeys } = await import('@main/store/secureStorage'); + + // Act + const result = await getAllApiKeys(); + + // Assert + expect(result).toEqual({ + anthropic: null, + openai: null, + google: null, + xai: null, + deepseek: null, + zai: null, + openrouter: null, + bedrock: null, + litellm: null, + custom: null, + }); + }); + + it('should return all stored API keys', async () => { + // Arrange + const { storeApiKey, getAllApiKeys } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-key'); + storeApiKey('openai', 'openai-key'); + storeApiKey('google', 'google-key'); + + // Act + const result = await getAllApiKeys(); + + // Assert + expect(result.anthropic).toBe('anthropic-key'); + expect(result.openai).toBe('openai-key'); + expect(result.google).toBe('google-key'); + expect(result.custom).toBeNull(); + }); + + it('should return partial results when some providers are set', async () => { + // Arrange + const { storeApiKey, getAllApiKeys } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-key'); + storeApiKey('custom', 'custom-key'); + + // Act + const result = await getAllApiKeys(); + + // Assert + expect(result.anthropic).toBe('anthropic-key'); + expect(result.openai).toBeNull(); + expect(result.google).toBeNull(); + expect(result.custom).toBe('custom-key'); + }); + }); + + describe('hasAnyApiKey', () => { + it('should return false when no keys are stored', async () => { + // Arrange + const { hasAnyApiKey } = await import('@main/store/secureStorage'); + + // Act + const result = await hasAnyApiKey(); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when at least one key is stored', async () => { + // Arrange + const { storeApiKey, hasAnyApiKey } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'test-key'); + + // Act + const result = await hasAnyApiKey(); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when multiple keys are stored', async () => { + // Arrange + const { storeApiKey, hasAnyApiKey } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-key'); + storeApiKey('openai', 'openai-key'); + + // Act + const result = await hasAnyApiKey(); + + // Assert + expect(result).toBe(true); + }); + + it('should return false after all keys are deleted', async () => { + // Arrange + const { storeApiKey, deleteApiKey, hasAnyApiKey } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'test-key'); + deleteApiKey('anthropic'); + + // Act + const result = await hasAnyApiKey(); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('clearSecureStorage', () => { + it('should remove all stored API keys', async () => { + // Arrange + const { storeApiKey, getAllApiKeys, clearSecureStorage } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-key'); + storeApiKey('openai', 'openai-key'); + storeApiKey('google', 'google-key'); + + // Act + clearSecureStorage(); + const result = await getAllApiKeys(); + + // Assert + expect(result).toEqual({ + anthropic: null, + openai: null, + google: null, + xai: null, + deepseek: null, + zai: null, + openrouter: null, + bedrock: null, + litellm: null, + custom: null, + }); + }); + + it('should allow storing new keys after clear', async () => { + // Arrange + const { storeApiKey, getApiKey, clearSecureStorage } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'old-key'); + clearSecureStorage(); + + // Act + storeApiKey('anthropic', 'new-key'); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe('new-key'); + }); + + it('should reset salt and derived key', async () => { + // Arrange + const { storeApiKey, getApiKey, clearSecureStorage } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'test-key-1'); + + // Act + clearSecureStorage(); + storeApiKey('anthropic', 'test-key-2'); + const result = getApiKey('anthropic'); + + // Assert - key should be retrievable with new encryption + expect(result).toBe('test-key-2'); + }); + }); + + describe('listStoredCredentials', () => { + it('should return empty array when no credentials stored', async () => { + // Arrange + const { listStoredCredentials } = await import('@main/store/secureStorage'); + + // Act + const result = listStoredCredentials(); + + // Assert + expect(result).toEqual([]); + }); + + it('should return all stored credentials with decrypted values', async () => { + // Arrange + const { storeApiKey, listStoredCredentials } = await import('@main/store/secureStorage'); + storeApiKey('anthropic', 'anthropic-key-123'); + storeApiKey('openai', 'openai-key-456'); + + // Act + const result = listStoredCredentials(); + + // Assert + expect(result).toHaveLength(2); + expect(result).toContainEqual({ account: 'apiKey:anthropic', password: 'anthropic-key-123' }); + expect(result).toContainEqual({ account: 'apiKey:openai', password: 'openai-key-456' }); + }); + }); + + describe('encryption consistency', () => { + it('should decrypt values correctly after module reload', async () => { + // Arrange - store key in first module instance + const module1 = await import('@main/store/secureStorage'); + module1.storeApiKey('anthropic', 'persistent-key-123'); + + // Act - reset modules and reimport + vi.resetModules(); + const module2 = await import('@main/store/secureStorage'); + const result = module2.getApiKey('anthropic'); + + // Assert + expect(result).toBe('persistent-key-123'); + }); + + it('should maintain encryption across multiple store/retrieve cycles', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + + // Act - multiple cycles + for (let i = 0; i < 5; i++) { + const key = `test-key-cycle-${i}`; + storeApiKey('anthropic', key); + const result = getApiKey('anthropic'); + expect(result).toBe(key); + } + }); + + it('should use unique IV for each encryption', async () => { + // This test verifies that the same plaintext produces different ciphertext + // due to random IV generation by storing the same value twice + // and confirming decryption works for both + const { storeApiKey, getApiKey, clearSecureStorage } = await import('@main/store/secureStorage'); + + // Store the same plaintext for two different providers + storeApiKey('anthropic', 'same-key-value'); + storeApiKey('openai', 'same-key-value'); + + // Both should decrypt correctly (proving unique IVs didn't break anything) + const anthropicKey = getApiKey('anthropic'); + const openaiKey = getApiKey('openai'); + + expect(anthropicKey).toBe('same-key-value'); + expect(openaiKey).toBe('same-key-value'); + + // If the IVs were the same, we'd have potential security issues, + // but since this is an integration test, we verify the functionality works. + // The encryption implementation uses crypto.randomBytes for IV generation. + }); + }); + + describe('edge cases', () => { + it('should handle unicode characters in API key', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + const unicodeKey = 'sk-test-key-with-unicode-chars'; + + // Act + storeApiKey('anthropic', unicodeKey); + const result = getApiKey('anthropic'); + + // Assert + expect(result).toBe(unicodeKey); + }); + + it('should handle rapid successive stores', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + + // Act - rapid stores + for (let i = 0; i < 10; i++) { + storeApiKey('anthropic', `key-${i}`); + } + const result = getApiKey('anthropic'); + + // Assert - should have the last stored value + expect(result).toBe('key-9'); + }); + + it('should handle concurrent operations on different providers', async () => { + // Arrange + const { storeApiKey, getApiKey } = await import('@main/store/secureStorage'); + + // Act - interleaved operations + storeApiKey('anthropic', 'a1'); + storeApiKey('openai', 'o1'); + storeApiKey('anthropic', 'a2'); + storeApiKey('google', 'g1'); + storeApiKey('openai', 'o2'); + + // Assert + expect(getApiKey('anthropic')).toBe('a2'); + expect(getApiKey('openai')).toBe('o2'); + expect(getApiKey('google')).toBe('g1'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts new file mode 100644 index 000000000..ed93e9a3d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts @@ -0,0 +1,244 @@ +/** + * Integration tests for Fresh Install Cleanup + * + * Tests the REAL checkAndCleanupFreshInstall function: + * - Returns false in dev mode (app.isPackaged = false) + * - Returns false when bundle mtime cannot be determined + * + * These tests mock external dependencies (electron, fs, store modules) + * and verify the actual module behavior. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Use vi.hoisted() to ensure mock functions are available when vi.mock is hoisted +const { + mockExistsSync, + mockReadFileSync, + mockWriteFileSync, + mockStatSync, + mockMkdirSync, + mockUnlinkSync, + mockGetPath, + mockGetVersion, + mockClearAppSettings, + mockClearTaskHistoryStore, + mockClearSecureStorage, +} = vi.hoisted(() => ({ + mockExistsSync: vi.fn(), + mockReadFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockStatSync: vi.fn(), + mockMkdirSync: vi.fn(), + mockUnlinkSync: vi.fn(), + mockGetPath: vi.fn(), + mockGetVersion: vi.fn(), + mockClearAppSettings: vi.fn(), + mockClearTaskHistoryStore: vi.fn(), + mockClearSecureStorage: vi.fn(), +})); + +// Mock fs module +vi.mock('fs', () => ({ + default: { + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + statSync: mockStatSync, + mkdirSync: mockMkdirSync, + unlinkSync: mockUnlinkSync, + }, + existsSync: mockExistsSync, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + statSync: mockStatSync, + mkdirSync: mockMkdirSync, + unlinkSync: mockUnlinkSync, +})); + +// Mock electron app - isPackaged starts as false (dev mode) +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: mockGetPath, + getVersion: mockGetVersion, + }, +})); + +// Mock store modules +vi.mock('@main/store/appSettings', () => ({ + clearAppSettings: mockClearAppSettings, +})); + +vi.mock('@main/store/taskHistory', () => ({ + clearTaskHistoryStore: mockClearTaskHistoryStore, +})); + +vi.mock('@main/store/secureStorage', () => ({ + clearSecureStorage: mockClearSecureStorage, +})); + +// Import the REAL module function after mocking dependencies +import { checkAndCleanupFreshInstall } from '@main/store/freshInstallCleanup'; +import { app } from 'electron'; + +describe('Fresh Install Cleanup Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset to dev mode by default + (app as unknown as { isPackaged: boolean }).isPackaged = false; + // Setup default path mocks + mockGetPath.mockImplementation((name: string) => { + const paths: Record = { + userData: '/tmp/test-app/userData', + appData: '/tmp/test-app/appData', + exe: '/Applications/Accomplish.app/Contents/MacOS/Accomplish', + }; + return paths[name] || '/tmp/test-app'; + }); + mockGetVersion.mockReturnValue('1.0.0'); + }); + + afterEach(() => { + vi.clearAllMocks(); + // Reset to dev mode + (app as unknown as { isPackaged: boolean }).isPackaged = false; + }); + + describe('checkAndCleanupFreshInstall', () => { + it('should return false in dev mode (app.isPackaged = false)', async () => { + // Arrange - dev mode is the default in beforeEach + expect(app.isPackaged).toBe(false); + + // Act - call the REAL function + const result = await checkAndCleanupFreshInstall(); + + // Assert + expect(result).toBe(false); + // Should not call any cleanup functions in dev mode + expect(mockClearAppSettings).not.toHaveBeenCalled(); + expect(mockClearTaskHistoryStore).not.toHaveBeenCalled(); + expect(mockClearSecureStorage).not.toHaveBeenCalled(); + }); + + it('should return false when exe path does not contain .app bundle', async () => { + // Arrange - set to packaged mode but with non-.app exe path + (app as unknown as { isPackaged: boolean }).isPackaged = true; + mockGetPath.mockImplementation((name: string) => { + if (name === 'exe') return '/usr/local/bin/accomplish'; // No .app in path + return '/tmp/test-app/userData'; + }); + + // Act + const result = await checkAndCleanupFreshInstall(); + + // Assert + expect(result).toBe(false); + }); + + it('should return false when bundle stat fails', async () => { + // Arrange - set to packaged mode with valid .app path but stat fails + (app as unknown as { isPackaged: boolean }).isPackaged = true; + mockStatSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + // Act + const result = await checkAndCleanupFreshInstall(); + + // Assert + expect(result).toBe(false); + }); + + it('should return false on first install (no existing data)', async () => { + // Arrange - packaged mode, valid bundle, but no existing data + (app as unknown as { isPackaged: boolean }).isPackaged = true; + const currentMtime = new Date('2024-06-01T00:00:00.000Z'); + mockStatSync.mockReturnValue({ mtime: currentMtime }); + mockExistsSync.mockReturnValue(false); // No existing marker or data + + // Act + const result = await checkAndCleanupFreshInstall(); + + // Assert - first install creates marker but doesn't cleanup (returns false) + expect(result).toBe(false); + // Should write the marker file + expect(mockWriteFileSync).toHaveBeenCalled(); + }); + + it('should return false when marker matches current bundle', async () => { + // Arrange - packaged mode, marker exists and matches + (app as unknown as { isPackaged: boolean }).isPackaged = true; + const currentMtime = new Date('2024-06-01T00:00:00.000Z'); + mockStatSync.mockReturnValue({ mtime: currentMtime }); + + const existingMarker = { + bundleMtime: currentMtime.toISOString(), + version: '1.0.0', + markerCreated: '2024-06-01T00:00:00.000Z', + }; + + mockExistsSync.mockImplementation((path: string) => { + return path.includes('.install-marker.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(existingMarker)); + + // Act + const result = await checkAndCleanupFreshInstall(); + + // Assert - no cleanup needed + expect(result).toBe(false); + expect(mockClearAppSettings).not.toHaveBeenCalled(); + }); + + it('should return true and cleanup when bundle mtime differs from marker', async () => { + // Arrange - packaged mode, marker exists but bundle changed + (app as unknown as { isPackaged: boolean }).isPackaged = true; + const currentMtime = new Date('2024-07-01T00:00:00.000Z'); // New version + mockStatSync.mockReturnValue({ mtime: currentMtime }); + + const existingMarker = { + bundleMtime: '2024-06-01T00:00:00.000Z', // Old version + version: '1.0.0', + markerCreated: '2024-06-01T00:00:00.000Z', + }; + + mockExistsSync.mockImplementation((path: string) => { + return path.includes('.install-marker.json'); + }); + mockReadFileSync.mockReturnValue(JSON.stringify(existingMarker)); + + // Act + const result = await checkAndCleanupFreshInstall(); + + // Assert - cleanup was performed + expect(result).toBe(true); + expect(mockClearAppSettings).toHaveBeenCalled(); + expect(mockClearTaskHistoryStore).toHaveBeenCalled(); + expect(mockClearSecureStorage).toHaveBeenCalled(); + }); + + it('should return true and cleanup on reinstall (existing data but no marker)', async () => { + // Arrange - packaged mode, no marker but has existing settings file + (app as unknown as { isPackaged: boolean }).isPackaged = true; + const currentMtime = new Date('2024-06-01T00:00:00.000Z'); + mockStatSync.mockReturnValue({ mtime: currentMtime }); + + // No marker, but app-settings.json exists + mockExistsSync.mockImplementation((path: string) => { + if (path.includes('.install-marker.json')) return false; + if (path.includes('app-settings.json')) return true; + return false; + }); + + // Act + const result = await checkAndCleanupFreshInstall(); + + // Assert - cleanup was performed (reinstall scenario) + expect(result).toBe(true); + expect(mockClearAppSettings).toHaveBeenCalled(); + expect(mockClearTaskHistoryStore).toHaveBeenCalled(); + expect(mockClearSecureStorage).toHaveBeenCalled(); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts new file mode 100644 index 000000000..28e364870 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts @@ -0,0 +1,625 @@ +/** + * Integration tests for taskHistory store + * Tests real electron-store interactions with task persistence + * @module __tests__/integration/main/taskHistory.integration.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { Task, TaskMessage } from '@accomplish/shared'; + +// Create a unique temp directory for each test run +let tempDir: string; +let originalCwd: string; + +// Use a factory function that closes over tempDir +const getTempDir = () => tempDir; + +// Mock electron module to control userData path +vi.mock('electron', () => ({ + app: { + getPath: (name: string) => { + if (name === 'userData') { + return getTempDir(); + } + return `/mock/path/${name}`; + }, + getVersion: () => '0.1.0', + getName: () => 'Accomplish', + isPackaged: false, + }, +})); + +// Helper to create a mock task +function createMockTask(id: string, prompt: string = 'Test task'): Task { + return { + id, + prompt, + status: 'pending', + messages: [], + createdAt: new Date().toISOString(), + }; +} + +// Helper to create a mock message +function createMockMessage( + id: string, + type: 'assistant' | 'user' | 'tool' | 'system' = 'assistant', + content: string = 'Test message' +): TaskMessage { + return { + id, + type, + content, + timestamp: new Date().toISOString(), + }; +} + +describe('taskHistory Integration', () => { + beforeEach(async () => { + // Create a unique temp directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taskHistory-test-')); + originalCwd = process.cwd(); + + // Reset module cache to get fresh electron-store instances + vi.resetModules(); + }); + + afterEach(async () => { + // Flush any pending writes and clear timeouts + try { + const { flushPendingTasks, clearTaskHistoryStore } = await import('@main/store/taskHistory'); + flushPendingTasks(); + clearTaskHistoryStore(); + } catch { + // Module may not be loaded + } + + // Clean up temp directory + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + process.chdir(originalCwd); + }); + + describe('saveTask and getTask', () => { + it('should save and retrieve a task by ID', async () => { + // Arrange + const { saveTask, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + const task = createMockTask('task-1', 'Save and retrieve test'); + + // Act + saveTask(task); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result).toBeDefined(); + expect(result?.id).toBe('task-1'); + expect(result?.prompt).toBe('Save and retrieve test'); + expect(result?.status).toBe('pending'); + }); + + it('should return undefined for non-existent task', async () => { + // Arrange + const { getTask } = await import('@main/store/taskHistory'); + + // Act + const result = getTask('non-existent'); + + // Assert + expect(result).toBeUndefined(); + }); + + it('should update existing task when saving with same ID', async () => { + // Arrange + const { saveTask, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + const task1 = createMockTask('task-1', 'Original prompt'); + const task2 = { ...createMockTask('task-1', 'Updated prompt'), status: 'running' as const }; + + // Act + saveTask(task1); + flushPendingTasks(); + saveTask(task2); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.prompt).toBe('Updated prompt'); + expect(result?.status).toBe('running'); + }); + + it('should preserve task messages when saving', async () => { + // Arrange + const { saveTask, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + const task: Task = { + ...createMockTask('task-1'), + messages: [ + createMockMessage('msg-1', 'user', 'Hello'), + createMockMessage('msg-2', 'assistant', 'Hi there'), + ], + }; + + // Act + saveTask(task); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.messages).toHaveLength(2); + expect(result?.messages[0].content).toBe('Hello'); + expect(result?.messages[1].content).toBe('Hi there'); + }); + + it('should preserve sessionId when saving', async () => { + // Arrange + const { saveTask, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + const task: Task = { + ...createMockTask('task-1'), + sessionId: 'session-abc-123', + }; + + // Act + saveTask(task); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.sessionId).toBe('session-abc-123'); + }); + }); + + describe('getTasks', () => { + it('should return empty array on fresh store', async () => { + // Arrange + const { getTasks } = await import('@main/store/taskHistory'); + + // Act + const result = getTasks(); + + // Assert + expect(result).toEqual([]); + }); + + it('should return all saved tasks', async () => { + // Arrange + const { saveTask, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1', 'Task 1')); + saveTask(createMockTask('task-2', 'Task 2')); + saveTask(createMockTask('task-3', 'Task 3')); + flushPendingTasks(); + + // Act + const result = getTasks(); + + // Assert + expect(result).toHaveLength(3); + }); + + it('should return tasks in reverse chronological order (newest first)', async () => { + // Arrange + const { saveTask, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1', 'First')); + saveTask(createMockTask('task-2', 'Second')); + saveTask(createMockTask('task-3', 'Third')); + flushPendingTasks(); + + // Act + const result = getTasks(); + + // Assert - newest should be first (tasks are unshifted) + expect(result[0].id).toBe('task-3'); + expect(result[1].id).toBe('task-2'); + expect(result[2].id).toBe('task-1'); + }); + }); + + describe('updateTaskStatus', () => { + it('should update task status without affecting other fields', async () => { + // Arrange + const { saveTask, updateTaskStatus, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + const task: Task = { + ...createMockTask('task-1', 'Status update test'), + messages: [createMockMessage('msg-1')], + sessionId: 'session-123', + }; + saveTask(task); + flushPendingTasks(); + + // Act + updateTaskStatus('task-1', 'completed'); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.status).toBe('completed'); + expect(result?.prompt).toBe('Status update test'); + expect(result?.messages).toHaveLength(1); + expect(result?.sessionId).toBe('session-123'); + }); + + it('should set completedAt when provided', async () => { + // Arrange + const { saveTask, updateTaskStatus, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + const completedAt = new Date().toISOString(); + + // Act + updateTaskStatus('task-1', 'completed', completedAt); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.status).toBe('completed'); + expect(result?.completedAt).toBe(completedAt); + }); + + it('should not modify non-existent task', async () => { + // Arrange + const { updateTaskStatus, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + + // Act + updateTaskStatus('non-existent', 'completed'); + flushPendingTasks(); + const result = getTasks(); + + // Assert + expect(result).toHaveLength(0); + }); + + it('should transition through various statuses correctly', async () => { + // Arrange + const { saveTask, updateTaskStatus, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + + // Act & Assert + updateTaskStatus('task-1', 'running'); + flushPendingTasks(); + expect(getTask('task-1')?.status).toBe('running'); + + updateTaskStatus('task-1', 'waiting_permission'); + flushPendingTasks(); + expect(getTask('task-1')?.status).toBe('waiting_permission'); + + updateTaskStatus('task-1', 'running'); + flushPendingTasks(); + expect(getTask('task-1')?.status).toBe('running'); + + updateTaskStatus('task-1', 'completed'); + flushPendingTasks(); + expect(getTask('task-1')?.status).toBe('completed'); + }); + }); + + describe('addTaskMessage', () => { + it('should append message to task', async () => { + // Arrange + const { saveTask, addTaskMessage, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + const message = createMockMessage('msg-1', 'assistant', 'Hello there'); + + // Act + addTaskMessage('task-1', message); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.messages).toHaveLength(1); + expect(result?.messages[0].content).toBe('Hello there'); + }); + + it('should append multiple messages in order', async () => { + // Arrange + const { saveTask, addTaskMessage, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + + // Act + addTaskMessage('task-1', createMockMessage('msg-1', 'user', 'First')); + addTaskMessage('task-1', createMockMessage('msg-2', 'assistant', 'Second')); + addTaskMessage('task-1', createMockMessage('msg-3', 'tool', 'Third')); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.messages).toHaveLength(3); + expect(result?.messages[0].content).toBe('First'); + expect(result?.messages[1].content).toBe('Second'); + expect(result?.messages[2].content).toBe('Third'); + }); + + it('should not modify non-existent task', async () => { + // Arrange + const { addTaskMessage, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + + // Act + addTaskMessage('non-existent', createMockMessage('msg-1')); + flushPendingTasks(); + const result = getTasks(); + + // Assert + expect(result).toHaveLength(0); + }); + + it('should preserve existing messages when adding new ones', async () => { + // Arrange + const { saveTask, addTaskMessage, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + const task: Task = { + ...createMockTask('task-1'), + messages: [createMockMessage('msg-1', 'user', 'Existing')], + }; + saveTask(task); + flushPendingTasks(); + + // Act + addTaskMessage('task-1', createMockMessage('msg-2', 'assistant', 'New')); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.messages).toHaveLength(2); + expect(result?.messages[0].content).toBe('Existing'); + expect(result?.messages[1].content).toBe('New'); + }); + }); + + describe('deleteTask', () => { + it('should remove only the target task', async () => { + // Arrange + const { saveTask, deleteTask, getTasks, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1', 'Keep this')); + saveTask(createMockTask('task-2', 'Delete this')); + saveTask(createMockTask('task-3', 'Keep this too')); + flushPendingTasks(); + + // Act + deleteTask('task-2'); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(2); + expect(getTask('task-1')).toBeDefined(); + expect(getTask('task-2')).toBeUndefined(); + expect(getTask('task-3')).toBeDefined(); + }); + + it('should handle deleting non-existent task gracefully', async () => { + // Arrange + const { saveTask, deleteTask, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + + // Act + deleteTask('non-existent'); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(1); + }); + + it('should allow deleting all tasks one by one', async () => { + // Arrange + const { saveTask, deleteTask, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + saveTask(createMockTask('task-2')); + flushPendingTasks(); + + // Act + deleteTask('task-1'); + deleteTask('task-2'); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(0); + }); + }); + + describe('clearHistory', () => { + it('should remove all tasks', async () => { + // Arrange + const { saveTask, clearHistory, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + saveTask(createMockTask('task-2')); + saveTask(createMockTask('task-3')); + flushPendingTasks(); + + // Act + clearHistory(); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(0); + }); + + it('should allow saving new tasks after clear', async () => { + // Arrange + const { saveTask, clearHistory, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + clearHistory(); + flushPendingTasks(); + + // Act + saveTask(createMockTask('task-new')); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(1); + expect(getTasks()[0].id).toBe('task-new'); + }); + }); + + describe('setMaxHistoryItems', () => { + it('should enforce history limit when saving new tasks', async () => { + // Arrange + const { saveTask, setMaxHistoryItems, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + setMaxHistoryItems(3); + + // Act - save more than the limit + saveTask(createMockTask('task-1')); + saveTask(createMockTask('task-2')); + saveTask(createMockTask('task-3')); + saveTask(createMockTask('task-4')); + saveTask(createMockTask('task-5')); + flushPendingTasks(); + + // Assert - should only keep 3 most recent + const tasks = getTasks(); + expect(tasks).toHaveLength(3); + expect(tasks[0].id).toBe('task-5'); + expect(tasks[1].id).toBe('task-4'); + expect(tasks[2].id).toBe('task-3'); + }); + + it('should trim existing history when limit is reduced', async () => { + // Arrange + const { saveTask, setMaxHistoryItems, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + saveTask(createMockTask('task-2')); + saveTask(createMockTask('task-3')); + saveTask(createMockTask('task-4')); + saveTask(createMockTask('task-5')); + flushPendingTasks(); + + // Act - reduce limit + setMaxHistoryItems(2); + flushPendingTasks(); + + // Assert + const tasks = getTasks(); + expect(tasks).toHaveLength(2); + expect(tasks[0].id).toBe('task-5'); + expect(tasks[1].id).toBe('task-4'); + }); + + it('should not affect history when limit is increased', async () => { + // Arrange + const { saveTask, setMaxHistoryItems, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + setMaxHistoryItems(3); + saveTask(createMockTask('task-1')); + saveTask(createMockTask('task-2')); + saveTask(createMockTask('task-3')); + flushPendingTasks(); + + // Act + setMaxHistoryItems(10); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(3); + }); + }); + + describe('debounced flush behavior', () => { + it('should batch rapid updates into single write', async () => { + // Arrange + const { saveTask, addTaskMessage, flushPendingTasks, getTask } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + + // Act - rapid updates without flush + addTaskMessage('task-1', createMockMessage('msg-1')); + addTaskMessage('task-1', createMockMessage('msg-2')); + addTaskMessage('task-1', createMockMessage('msg-3')); + + // Force flush + flushPendingTasks(); + + // Assert + const task = getTask('task-1'); + expect(task?.messages).toHaveLength(3); + }); + + it('should flush pending tasks when explicitly called', async () => { + // Arrange + const { saveTask, flushPendingTasks, getTasks } = await import('@main/store/taskHistory'); + + // Act - save without waiting for debounce + saveTask(createMockTask('task-1')); + flushPendingTasks(); + + // Assert - task should be persisted immediately + const tasks = getTasks(); + expect(tasks).toHaveLength(1); + }); + + it('should handle interleaved saves and reads correctly', async () => { + // Arrange + const { saveTask, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + + // Act + saveTask(createMockTask('task-1', 'First')); + const afterFirst = getTask('task-1'); + + saveTask(createMockTask('task-2', 'Second')); + const afterSecond = getTask('task-2'); + + flushPendingTasks(); + + // Assert - both should be readable even before flush + expect(afterFirst?.prompt).toBe('First'); + expect(afterSecond?.prompt).toBe('Second'); + }); + }); + + describe('updateTaskSessionId', () => { + it('should update session ID for existing task', async () => { + // Arrange + const { saveTask, updateTaskSessionId, getTask, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + flushPendingTasks(); + + // Act + updateTaskSessionId('task-1', 'new-session-xyz'); + flushPendingTasks(); + const result = getTask('task-1'); + + // Assert + expect(result?.sessionId).toBe('new-session-xyz'); + }); + + it('should not modify non-existent task', async () => { + // Arrange + const { updateTaskSessionId, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + + // Act + updateTaskSessionId('non-existent', 'session-123'); + flushPendingTasks(); + + // Assert + expect(getTasks()).toHaveLength(0); + }); + }); + + describe('clearTaskHistoryStore', () => { + it('should reset store to defaults', async () => { + // Arrange + const { saveTask, clearTaskHistoryStore, getTasks, flushPendingTasks } = await import('@main/store/taskHistory'); + saveTask(createMockTask('task-1')); + saveTask(createMockTask('task-2')); + flushPendingTasks(); + + // Act + clearTaskHistoryStore(); + + // Assert + expect(getTasks()).toHaveLength(0); + }); + + it('should clear pending writes without persisting them', async () => { + // Arrange + const { saveTask, clearTaskHistoryStore, getTasks } = await import('@main/store/taskHistory'); + + // Act - save without flush, then clear + saveTask(createMockTask('task-1')); + clearTaskHistoryStore(); + + // Assert - pending task should not be persisted + expect(getTasks()).toHaveLength(0); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts new file mode 100644 index 000000000..dd5f44c7b --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts @@ -0,0 +1,449 @@ +/** + * Integration tests for Bundled Node.js utilities + * + * Tests the bundled-node module which provides paths to bundled Node.js + * binaries for packaged Electron apps. + * + * @module __tests__/integration/main/utils/bundled-node.integration.test + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import path from 'path'; + +// Store original values +const originalPlatform = process.platform; +const originalArch = process.arch; + +// Mock electron module +const mockApp = { + isPackaged: false, +}; + +vi.mock('electron', () => ({ + app: mockApp, +})); + +// Mock fs module +const mockFs = { + existsSync: vi.fn(), +}; + +vi.mock('fs', () => ({ + default: mockFs, + existsSync: mockFs.existsSync, +})); + +describe('Bundled Node.js Utilities', () => { + let getBundledNodePaths: typeof import('@main/utils/bundled-node').getBundledNodePaths; + let isBundledNodeAvailable: typeof import('@main/utils/bundled-node').isBundledNodeAvailable; + let getNodePath: typeof import('@main/utils/bundled-node').getNodePath; + let getNpmPath: typeof import('@main/utils/bundled-node').getNpmPath; + let getNpxPath: typeof import('@main/utils/bundled-node').getNpxPath; + let logBundledNodeInfo: typeof import('@main/utils/bundled-node').logBundledNodeInfo; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + mockApp.isPackaged = false; + + // Re-import module to get fresh state + const module = await import('@main/utils/bundled-node'); + getBundledNodePaths = module.getBundledNodePaths; + isBundledNodeAvailable = module.isBundledNodeAvailable; + getNodePath = module.getNodePath; + getNpmPath = module.getNpmPath; + getNpxPath = module.getNpxPath; + logBundledNodeInfo = module.logBundledNodeInfo; + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Restore platform/arch + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + describe('getBundledNodePaths()', () => { + describe('Development Mode', () => { + it('should return null in development mode', () => { + // Arrange + mockApp.isPackaged = false; + + // Act + const result = getBundledNodePaths(); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('Packaged Mode - macOS (darwin)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + it('should return correct paths for arm64 architecture', async () => { + // Arrange + mockApp.isPackaged = true; + Object.defineProperty(process, 'arch', { value: 'arm64' }); + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + // Re-import to pick up new process values + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + const paths = module.getBundledNodePaths(); + + // Assert + expect(paths).not.toBeNull(); + expect(paths!.nodeDir).toBe(path.join(resourcesPath, 'nodejs', 'arm64')); + expect(paths!.binDir).toBe(path.join(resourcesPath, 'nodejs', 'arm64', 'bin')); + expect(paths!.nodePath).toBe(path.join(resourcesPath, 'nodejs', 'arm64', 'bin', 'node')); + expect(paths!.npmPath).toBe(path.join(resourcesPath, 'nodejs', 'arm64', 'bin', 'npm')); + expect(paths!.npxPath).toBe(path.join(resourcesPath, 'nodejs', 'arm64', 'bin', 'npx')); + }); + + it('should return correct paths for x64 architecture', async () => { + // Arrange + mockApp.isPackaged = true; + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + // Re-import to pick up new process values + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + const paths = module.getBundledNodePaths(); + + // Assert + expect(paths).not.toBeNull(); + expect(paths!.nodeDir).toBe(path.join(resourcesPath, 'nodejs', 'x64')); + expect(paths!.binDir).toBe(path.join(resourcesPath, 'nodejs', 'x64', 'bin')); + }); + }); + + describe('Packaged Mode - Windows (win32)', () => { + it('should return correct paths for Windows', async () => { + // Arrange + mockApp.isPackaged = true; + Object.defineProperty(process, 'platform', { value: 'win32' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resourcesPath = 'C:\\Program Files\\Accomplish\\resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + // Re-import to pick up new process values + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + const paths = module.getBundledNodePaths(); + + // Assert + expect(paths).not.toBeNull(); + expect(paths!.nodeDir).toBe(path.join(resourcesPath, 'nodejs', 'x64')); + // Windows: binDir is same as nodeDir + expect(paths!.binDir).toBe(path.join(resourcesPath, 'nodejs', 'x64')); + expect(paths!.nodePath).toBe(path.join(resourcesPath, 'nodejs', 'x64', 'node.exe')); + expect(paths!.npmPath).toBe(path.join(resourcesPath, 'nodejs', 'x64', 'npm.cmd')); + expect(paths!.npxPath).toBe(path.join(resourcesPath, 'nodejs', 'x64', 'npx.cmd')); + }); + }); + }); + + describe('isBundledNodeAvailable()', () => { + it('should return false in development mode', () => { + // Arrange + mockApp.isPackaged = false; + + // Act + const result = isBundledNodeAvailable(); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when bundled node exists', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(true); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Act + const result = module.isBundledNodeAvailable(); + + // Assert + expect(result).toBe(true); + expect(mockFs.existsSync).toHaveBeenCalled(); + }); + + it('should return false when bundled node does not exist', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Act + const result = module.isBundledNodeAvailable(); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('getNodePath()', () => { + it('should return "node" in development mode', () => { + // Arrange + mockApp.isPackaged = false; + + // Act + const result = getNodePath(); + + // Assert + expect(result).toBe('node'); + }); + + it('should return bundled node path when available', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(true); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Act + const result = module.getNodePath(); + + // Assert + expect(result).toContain('node'); + expect(result).not.toBe('node'); // Should be full path + }); + + it('should fallback to "node" when bundled not found in packaged app', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Spy on console.warn + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Act + const result = module.getNodePath(); + + // Assert + expect(result).toBe('node'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('WARNING: Bundled Node.js not found') + ); + + warnSpy.mockRestore(); + }); + }); + + describe('getNpmPath()', () => { + it('should return "npm" in development mode', () => { + // Arrange + mockApp.isPackaged = false; + + // Act + const result = getNpmPath(); + + // Assert + expect(result).toBe('npm'); + }); + + it('should return bundled npm path when available', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(true); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Act + const result = module.getNpmPath(); + + // Assert + expect(result).toContain('npm'); + expect(result).not.toBe('npm'); // Should be full path + }); + + it('should fallback to "npm" when bundled not found', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Suppress console.warn + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Act + const result = module.getNpmPath(); + + // Assert + expect(result).toBe('npm'); + }); + }); + + describe('getNpxPath()', () => { + it('should return "npx" in development mode', () => { + // Arrange + mockApp.isPackaged = false; + + // Act + const result = getNpxPath(); + + // Assert + expect(result).toBe('npx'); + }); + + it('should return bundled npx path when available', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(true); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Act + const result = module.getNpxPath(); + + // Assert + expect(result).toContain('npx'); + expect(result).not.toBe('npx'); // Should be full path + }); + + it('should fallback to "npx" when bundled not found', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(false); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Suppress console.warn + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Act + const result = module.getNpxPath(); + + // Assert + expect(result).toBe('npx'); + }); + }); + + describe('logBundledNodeInfo()', () => { + it('should log development mode message when not packaged', () => { + // Arrange + mockApp.isPackaged = false; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Act + logBundledNodeInfo(); + + // Assert + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Development mode') + ); + + logSpy.mockRestore(); + }); + + it('should log bundled node configuration when packaged', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + mockFs.existsSync.mockReturnValue(true); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Act + module.logBundledNodeInfo(); + + // Assert + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Configuration')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Platform')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Architecture')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Node directory')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Node path')); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Available')); + + logSpy.mockRestore(); + }); + }); + + describe('BundledNodePaths Interface', () => { + it('should return all required path properties', async () => { + // Arrange + mockApp.isPackaged = true; + const resourcesPath = '/Applications/Accomplish.app/Contents/Resources'; + (process as NodeJS.Process & { resourcesPath: string }).resourcesPath = resourcesPath; + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/bundled-node'); + + // Act + const paths = module.getBundledNodePaths(); + + // Assert + expect(paths).not.toBeNull(); + expect(paths).toHaveProperty('nodePath'); + expect(paths).toHaveProperty('npmPath'); + expect(paths).toHaveProperty('npxPath'); + expect(paths).toHaveProperty('binDir'); + expect(paths).toHaveProperty('nodeDir'); + + // All should be strings + expect(typeof paths!.nodePath).toBe('string'); + expect(typeof paths!.npmPath).toBe('string'); + expect(typeof paths!.npxPath).toBe('string'); + expect(typeof paths!.binDir).toBe('string'); + expect(typeof paths!.nodeDir).toBe('string'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts new file mode 100644 index 000000000..9a6d6c268 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts @@ -0,0 +1,513 @@ +/** + * Integration tests for System PATH utilities + * + * Tests the system-path module which builds extended PATH strings for + * finding Node.js tools in macOS packaged apps. + * + * @module __tests__/integration/main/utils/system-path.integration.test + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import path from 'path'; + +// Store original values +const originalPlatform = process.platform; +const originalEnv = { ...process.env }; + +// Mock fs module +const mockFs = { + existsSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + accessSync: vi.fn(), + constants: { + X_OK: 1, + }, +}; + +vi.mock('fs', () => ({ + default: mockFs, + existsSync: mockFs.existsSync, + readdirSync: mockFs.readdirSync, + statSync: mockFs.statSync, + accessSync: mockFs.accessSync, + constants: mockFs.constants, +})); + +// Mock child_process +const mockExecSync = vi.fn(); + +vi.mock('child_process', () => ({ + execSync: mockExecSync, +})); + +describe('System PATH Utilities', () => { + let getExtendedNodePath: typeof import('@main/utils/system-path').getExtendedNodePath; + let findCommandInPath: typeof import('@main/utils/system-path').findCommandInPath; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + // Reset environment + process.env = { ...originalEnv }; + process.env.HOME = '/Users/testuser'; + + // Re-import module to get fresh state + const module = await import('@main/utils/system-path'); + getExtendedNodePath = module.getExtendedNodePath; + findCommandInPath = module.findCommandInPath; + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = originalEnv; + }); + + describe('getExtendedNodePath()', () => { + describe('Non-macOS Platforms', () => { + it('should return base PATH unchanged on Linux', async () => { + // Arrange + Object.defineProperty(process, 'platform', { value: 'linux' }); + const basePath = '/usr/bin:/usr/local/bin'; + + // Re-import for platform change + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(basePath); + + // Assert + expect(result).toBe(basePath); + }); + + it('should return base PATH unchanged on Windows', async () => { + // Arrange + Object.defineProperty(process, 'platform', { value: 'win32' }); + const basePath = 'C:\\Windows\\System32'; + + // Re-import for platform change + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(basePath); + + // Assert + expect(result).toBe(basePath); + }); + }); + + describe('macOS Platform', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + it('should include common Node.js paths', async () => { + // Arrange + mockFs.existsSync.mockImplementation((p: string) => { + const existingPaths = [ + '/opt/homebrew/bin', + '/usr/local/bin', + ]; + return existingPaths.includes(p); + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('PATH="/usr/bin:/bin"; export PATH;'); + + // Re-import for platform change + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath('/original/path'); + + // Assert + expect(result).toContain('/opt/homebrew/bin'); + expect(result).toContain('/usr/local/bin'); + }); + + it('should include NVM paths when available', async () => { + // Arrange + const nvmPath = '/Users/testuser/.nvm/versions/node/v20.10.0/bin'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === '/Users/testuser/.nvm/versions/node') return true; + if (p === nvmPath) return true; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === '/Users/testuser/.nvm/versions/node') return ['v20.10.0']; + return []; + }); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + + // Assert + expect(result).toContain(nvmPath); + }); + + it('should include fnm paths when available', async () => { + // Arrange + const fnmPath = '/Users/testuser/.fnm/node-versions/v20.10.0/installation/bin'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === '/Users/testuser/.fnm/node-versions') return true; + if (p === fnmPath) return true; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === '/Users/testuser/.fnm/node-versions') return ['v20.10.0']; + return []; + }); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + + // Assert + expect(result).toContain(fnmPath); + }); + + it('should sort NVM versions with newest first', async () => { + // Arrange + const nvmDir = '/Users/testuser/.nvm/versions/node'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === nvmDir) return true; + if (p.includes('.nvm/versions/node/v')) return true; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === nvmDir) return ['v18.17.0', 'v20.10.0', 'v16.20.0']; + return []; + }); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + const pathParts = result.split(':'); + + // Assert - v20 should come before v18 which should come before v16 + const v20Index = pathParts.findIndex((p) => p.includes('v20')); + const v18Index = pathParts.findIndex((p) => p.includes('v18')); + const v16Index = pathParts.findIndex((p) => p.includes('v16')); + + expect(v20Index).toBeLessThan(v18Index); + expect(v18Index).toBeLessThan(v16Index); + }); + + it('should include path_helper output', async () => { + // Arrange + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('PATH="/custom/path:/another/path"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + + // Assert + expect(result).toContain('/custom/path'); + expect(result).toContain('/another/path'); + }); + + it('should handle path_helper failure gracefully', async () => { + // Arrange + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockImplementation(() => { + throw new Error('path_helper failed'); + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act - should not throw + const result = module.getExtendedNodePath('/base/path'); + + // Assert + expect(result).toContain('/base/path'); + warnSpy.mockRestore(); + }); + + it('should deduplicate paths', async () => { + // Arrange + mockFs.existsSync.mockImplementation((p: string) => { + return p === '/usr/local/bin'; + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('PATH="/usr/local/bin:/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath('/usr/local/bin'); + + // Assert - /usr/local/bin should appear only once + const pathParts = result.split(':'); + const localBinCount = pathParts.filter((p) => p === '/usr/local/bin').length; + expect(localBinCount).toBe(1); + }); + + it('should use process.env.PATH as default base', async () => { + // Arrange + process.env.PATH = '/default/env/path'; + mockFs.existsSync.mockReturnValue(false); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(); + + // Assert + expect(result).toContain('/default/env/path'); + }); + + it('should include Volta path when available', async () => { + // Arrange + const voltaPath = '/Users/testuser/.volta/bin'; + + mockFs.existsSync.mockImplementation((p: string) => { + return p === voltaPath; + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + + // Assert + expect(result).toContain(voltaPath); + }); + + it('should include asdf shims path when available', async () => { + // Arrange + const asdfPath = '/Users/testuser/.asdf/shims'; + + mockFs.existsSync.mockImplementation((p: string) => { + return p === asdfPath; + }); + mockFs.readdirSync.mockReturnValue([]); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + + // Assert + expect(result).toContain(asdfPath); + }); + }); + }); + + describe('findCommandInPath()', () => { + it('should find executable command in PATH', () => { + // Arrange + const searchPath = '/usr/bin:/usr/local/bin'; + const expectedPath = '/usr/local/bin/node'; + + mockFs.existsSync.mockImplementation((p: string) => { + return p === expectedPath; + }); + mockFs.statSync.mockReturnValue({ isFile: () => true }); + mockFs.accessSync.mockImplementation(() => {}); // No throw = executable + + // Act + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBe(expectedPath); + }); + + it('should return null when command not found', () => { + // Arrange + const searchPath = '/usr/bin:/usr/local/bin'; + mockFs.existsSync.mockReturnValue(false); + + // Act + const result = findCommandInPath('nonexistent', searchPath); + + // Assert + expect(result).toBeNull(); + }); + + it('should skip non-file entries', () => { + // Arrange + const searchPath = '/usr/bin'; + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isFile: () => false }); // Directory + + // Act + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBeNull(); + }); + + it('should skip non-executable files', () => { + // Arrange + const searchPath = '/usr/bin'; + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockReturnValue({ isFile: () => true }); + mockFs.accessSync.mockImplementation(() => { + throw new Error('Not executable'); + }); + + // Act + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBeNull(); + }); + + it('should search directories in order', () => { + // Arrange + const searchPath = '/first/bin:/second/bin'; + const firstPath = '/first/bin/node'; + const secondPath = '/second/bin/node'; + + mockFs.existsSync.mockImplementation((p: string) => { + return p === firstPath || p === secondPath; + }); + mockFs.statSync.mockReturnValue({ isFile: () => true }); + mockFs.accessSync.mockImplementation(() => {}); + + // Act + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBe(firstPath); + }); + + it('should handle empty path segments', () => { + // Arrange + const searchPath = '/usr/bin::/usr/local/bin'; + const expectedPath = '/usr/local/bin/node'; + + mockFs.existsSync.mockImplementation((p: string) => { + return p === expectedPath; + }); + mockFs.statSync.mockReturnValue({ isFile: () => true }); + mockFs.accessSync.mockImplementation(() => {}); + + // Act + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBe(expectedPath); + }); + + it('should handle directory access errors gracefully', () => { + // Arrange + const searchPath = '/nonexistent:/usr/local/bin'; + const expectedPath = '/usr/local/bin/node'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p.startsWith('/nonexistent')) { + throw new Error('Directory does not exist'); + } + return p === expectedPath; + }); + mockFs.statSync.mockReturnValue({ isFile: () => true }); + mockFs.accessSync.mockImplementation(() => {}); + + // Act - should not throw + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBe(expectedPath); + }); + + it('should handle statSync errors gracefully', () => { + // Arrange + const searchPath = '/usr/bin:/usr/local/bin'; + const expectedPath = '/usr/local/bin/node'; + + mockFs.existsSync.mockReturnValue(true); + mockFs.statSync.mockImplementation((p: string) => { + if (p === '/usr/bin/node') { + throw new Error('Stat error'); + } + return { isFile: () => p === expectedPath }; + }); + mockFs.accessSync.mockImplementation(() => {}); + + // Act + const result = findCommandInPath('node', searchPath); + + // Assert + expect(result).toBe(expectedPath); + }); + }); + + describe('Path Priority Order', () => { + it('should prioritize version manager paths over system paths', async () => { + // Arrange + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const nvmPath = '/Users/testuser/.nvm/versions/node/v20.10.0/bin'; + + mockFs.existsSync.mockImplementation((p: string) => { + if (p === '/Users/testuser/.nvm/versions/node') return true; + if (p === nvmPath) return true; + if (p === '/opt/homebrew/bin') return true; + if (p === '/usr/local/bin') return true; + return false; + }); + mockFs.readdirSync.mockImplementation((p: string) => { + if (p === '/Users/testuser/.nvm/versions/node') return ['v20.10.0']; + return []; + }); + mockExecSync.mockReturnValue('PATH="/usr/bin"; export PATH;'); + + // Re-import + vi.resetModules(); + const module = await import('@main/utils/system-path'); + + // Act + const result = module.getExtendedNodePath(''); + const pathParts = result.split(':'); + + // Assert - NVM should come before Homebrew + const nvmIndex = pathParts.findIndex((p) => p.includes('.nvm')); + const homebrewIndex = pathParts.findIndex((p) => p.includes('homebrew')); + + expect(nvmIndex).toBeLessThan(homebrewIndex); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts new file mode 100644 index 000000000..c3e9c3341 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts @@ -0,0 +1,323 @@ +/** + * Integration tests for Preload script + * + * Tests the REAL preload script by: + * 1. Mocking electron APIs (external dependency) + * 2. Importing the real preload module (triggers contextBridge.exposeInMainWorld) + * 3. Verifying the exposed API calls the correct IPC channels + * + * This is a proper integration test - only external dependencies are mocked. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import pkg from '../../../package.json'; + +// Create mock functions for electron +const mockExposeInMainWorld = vi.fn(); +const mockInvoke = vi.fn(() => Promise.resolve(undefined)); +const mockOn = vi.fn(); +const mockRemoveListener = vi.fn(); + +// Mock electron module before importing preload +vi.mock('electron', () => ({ + contextBridge: { + exposeInMainWorld: mockExposeInMainWorld, + }, + ipcRenderer: { + invoke: mockInvoke, + on: mockOn, + removeListener: mockRemoveListener, + }, +})); + +// Store captured APIs from exposeInMainWorld calls +let capturedAccomplishAPI: Record = {}; +let capturedAccomplishShell: Record = {}; + +describe('Preload Script Integration', () => { + beforeEach(async () => { + vi.clearAllMocks(); + capturedAccomplishAPI = {}; + capturedAccomplishShell = {}; + + // Capture what the real preload exposes + mockExposeInMainWorld.mockImplementation((name: string, api: unknown) => { + if (name === 'accomplish') { + capturedAccomplishAPI = api as Record; + } else if (name === 'accomplishShell') { + capturedAccomplishShell = api as Record; + } + }); + + // Reset module cache and import the REAL preload module + vi.resetModules(); + await import('../../../src/preload/index'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('API Exposure', () => { + it('should expose accomplish API via contextBridge', () => { + expect(mockExposeInMainWorld).toHaveBeenCalledWith('accomplish', expect.any(Object)); + expect(capturedAccomplishAPI).toBeDefined(); + }); + + it('should expose accomplishShell info via contextBridge', () => { + expect(mockExposeInMainWorld).toHaveBeenCalledWith('accomplishShell', expect.any(Object)); + expect(capturedAccomplishShell).toBeDefined(); + }); + + it('should expose shell info with isElectron=true', () => { + expect(capturedAccomplishShell.isElectron).toBe(true); + }); + + it('should expose shell info with platform', () => { + expect(capturedAccomplishShell.platform).toBe(process.platform); + }); + + it('should expose shell info with version matching package.json', () => { + expect(capturedAccomplishShell.version).toBe(pkg.version); + }); + }); + + describe('IPC Method Invocations', () => { + describe('App Info', () => { + it('getVersion should invoke app:version', async () => { + await (capturedAccomplishAPI.getVersion as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('app:version'); + }); + + it('getPlatform should invoke app:platform', async () => { + await (capturedAccomplishAPI.getPlatform as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('app:platform'); + }); + }); + + describe('Shell Operations', () => { + it('openExternal should invoke shell:open-external with URL', async () => { + const url = 'https://example.com'; + await (capturedAccomplishAPI.openExternal as (url: string) => Promise)(url); + expect(mockInvoke).toHaveBeenCalledWith('shell:open-external', url); + }); + }); + + describe('Task Operations', () => { + it('startTask should invoke task:start with config', async () => { + const config = { description: 'Test task' }; + await (capturedAccomplishAPI.startTask as (config: { description: string }) => Promise)(config); + expect(mockInvoke).toHaveBeenCalledWith('task:start', config); + }); + + it('cancelTask should invoke task:cancel with taskId', async () => { + await (capturedAccomplishAPI.cancelTask as (taskId: string) => Promise)('task_123'); + expect(mockInvoke).toHaveBeenCalledWith('task:cancel', 'task_123'); + }); + + it('interruptTask should invoke task:interrupt with taskId', async () => { + await (capturedAccomplishAPI.interruptTask as (taskId: string) => Promise)('task_123'); + expect(mockInvoke).toHaveBeenCalledWith('task:interrupt', 'task_123'); + }); + + it('getTask should invoke task:get with taskId', async () => { + await (capturedAccomplishAPI.getTask as (taskId: string) => Promise)('task_123'); + expect(mockInvoke).toHaveBeenCalledWith('task:get', 'task_123'); + }); + + it('listTasks should invoke task:list', async () => { + await (capturedAccomplishAPI.listTasks as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('task:list'); + }); + + it('deleteTask should invoke task:delete with taskId', async () => { + await (capturedAccomplishAPI.deleteTask as (taskId: string) => Promise)('task_123'); + expect(mockInvoke).toHaveBeenCalledWith('task:delete', 'task_123'); + }); + + it('clearTaskHistory should invoke task:clear-history', async () => { + await (capturedAccomplishAPI.clearTaskHistory as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('task:clear-history'); + }); + }); + + describe('Permission Operations', () => { + it('respondToPermission should invoke permission:respond', async () => { + const response = { taskId: 'task_123', allowed: true }; + await (capturedAccomplishAPI.respondToPermission as (r: { taskId: string; allowed: boolean }) => Promise)(response); + expect(mockInvoke).toHaveBeenCalledWith('permission:respond', response); + }); + }); + + describe('Session Operations', () => { + it('resumeSession should invoke session:resume', async () => { + await (capturedAccomplishAPI.resumeSession as (s: string, p: string, t?: string) => Promise)('session_123', 'Continue', 'task_456'); + expect(mockInvoke).toHaveBeenCalledWith('session:resume', 'session_123', 'Continue', 'task_456'); + }); + }); + + describe('Settings Operations', () => { + it('getDebugMode should invoke settings:debug-mode', async () => { + await (capturedAccomplishAPI.getDebugMode as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('settings:debug-mode'); + }); + + it('setDebugMode should invoke settings:set-debug-mode', async () => { + await (capturedAccomplishAPI.setDebugMode as (enabled: boolean) => Promise)(true); + expect(mockInvoke).toHaveBeenCalledWith('settings:set-debug-mode', true); + }); + + it('getAppSettings should invoke settings:app-settings', async () => { + await (capturedAccomplishAPI.getAppSettings as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('settings:app-settings'); + }); + }); + + describe('API Key Operations', () => { + it('hasApiKey should invoke api-key:exists', async () => { + await (capturedAccomplishAPI.hasApiKey as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('api-key:exists'); + }); + + it('setApiKey should invoke api-key:set', async () => { + await (capturedAccomplishAPI.setApiKey as (key: string) => Promise)('sk-test'); + expect(mockInvoke).toHaveBeenCalledWith('api-key:set', 'sk-test'); + }); + + it('getApiKey should invoke api-key:get', async () => { + await (capturedAccomplishAPI.getApiKey as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('api-key:get'); + }); + + it('validateApiKey should invoke api-key:validate', async () => { + await (capturedAccomplishAPI.validateApiKey as (key: string) => Promise)('sk-test'); + expect(mockInvoke).toHaveBeenCalledWith('api-key:validate', 'sk-test'); + }); + + it('clearApiKey should invoke api-key:clear', async () => { + await (capturedAccomplishAPI.clearApiKey as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('api-key:clear'); + }); + + it('getAllApiKeys should invoke api-keys:all', async () => { + await (capturedAccomplishAPI.getAllApiKeys as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('api-keys:all'); + }); + + it('hasAnyApiKey should invoke api-keys:has-any', async () => { + await (capturedAccomplishAPI.hasAnyApiKey as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('api-keys:has-any'); + }); + }); + + describe('Onboarding Operations', () => { + it('getOnboardingComplete should invoke onboarding:complete', async () => { + await (capturedAccomplishAPI.getOnboardingComplete as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('onboarding:complete'); + }); + + it('setOnboardingComplete should invoke onboarding:set-complete', async () => { + await (capturedAccomplishAPI.setOnboardingComplete as (c: boolean) => Promise)(true); + expect(mockInvoke).toHaveBeenCalledWith('onboarding:set-complete', true); + }); + }); + + describe('Model Operations', () => { + it('getSelectedModel should invoke model:get', async () => { + await (capturedAccomplishAPI.getSelectedModel as () => Promise)(); + expect(mockInvoke).toHaveBeenCalledWith('model:get'); + }); + + it('setSelectedModel should invoke model:set', async () => { + const model = { provider: 'anthropic', model: 'claude-3-opus' }; + await (capturedAccomplishAPI.setSelectedModel as (m: { provider: string; model: string }) => Promise)(model); + expect(mockInvoke).toHaveBeenCalledWith('model:set', model); + }); + }); + + describe('Logging Operations', () => { + it('logEvent should invoke log:event', async () => { + const payload = { level: 'info', message: 'Test' }; + await (capturedAccomplishAPI.logEvent as (p: unknown) => Promise)(payload); + expect(mockInvoke).toHaveBeenCalledWith('log:event', payload); + }); + }); + }); + + describe('Event Subscriptions', () => { + it('onTaskUpdate should subscribe to task:update', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onTaskUpdate as (cb: (e: unknown) => void) => () => void)(callback); + expect(mockOn).toHaveBeenCalledWith('task:update', expect.any(Function)); + }); + + it('onTaskUpdate should return unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = (capturedAccomplishAPI.onTaskUpdate as (cb: (e: unknown) => void) => () => void)(callback); + unsubscribe(); + expect(mockRemoveListener).toHaveBeenCalledWith('task:update', expect.any(Function)); + }); + + it('onTaskUpdateBatch should subscribe to task:update:batch', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onTaskUpdateBatch as (cb: (e: unknown) => void) => () => void)(callback); + expect(mockOn).toHaveBeenCalledWith('task:update:batch', expect.any(Function)); + }); + + it('onPermissionRequest should subscribe to permission:request', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onPermissionRequest as (cb: (e: unknown) => void) => () => void)(callback); + expect(mockOn).toHaveBeenCalledWith('permission:request', expect.any(Function)); + }); + + it('onTaskProgress should subscribe to task:progress', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onTaskProgress as (cb: (e: unknown) => void) => () => void)(callback); + expect(mockOn).toHaveBeenCalledWith('task:progress', expect.any(Function)); + }); + + it('onDebugLog should subscribe to debug:log', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onDebugLog as (cb: (e: unknown) => void) => () => void)(callback); + expect(mockOn).toHaveBeenCalledWith('debug:log', expect.any(Function)); + }); + + it('onTaskStatusChange should subscribe to task:status-change', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onTaskStatusChange as (cb: (e: unknown) => void) => () => void)(callback); + expect(mockOn).toHaveBeenCalledWith('task:status-change', expect.any(Function)); + }); + }); + + describe('Event Callback Invocation', () => { + it('onTaskUpdate callback should receive event data', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onTaskUpdate as (cb: (e: unknown) => void) => () => void)(callback); + + // Get the registered listener from mockOn calls + const registeredListener = mockOn.mock.calls.find( + (call: unknown[]) => call[0] === 'task:update' + )?.[1] as (event: unknown, data: unknown) => void; + + // Simulate IPC event + const eventData = { taskId: 'task_123', type: 'message' }; + registeredListener(null, eventData); + + expect(callback).toHaveBeenCalledWith(eventData); + }); + + it('onPermissionRequest callback should receive request data', () => { + const callback = vi.fn(); + (capturedAccomplishAPI.onPermissionRequest as (cb: (e: unknown) => void) => () => void)(callback); + + const registeredListener = mockOn.mock.calls.find( + (call: unknown[]) => call[0] === 'permission:request' + )?.[1] as (event: unknown, data: unknown) => void; + + const requestData = { id: 'req_123', taskId: 'task_456' }; + registeredListener(null, requestData); + + expect(callback).toHaveBeenCalledWith(requestData); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx new file mode 100644 index 000000000..e1cde7814 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx @@ -0,0 +1,370 @@ +/** + * Integration tests for App component + * Tests router setup and route rendering + * + * NOTE: This test follows React component integration testing principles: + * - Mocks external boundaries (IPC API, analytics) - cannot run real Electron in vitest + * - Mocks animation libraries (framer-motion) - for test stability + * - Mocks child page components - to focus on App's coordination logic + * - Uses real router (MemoryRouter) for route testing + * + * For full component rendering integration, see individual component tests. + * + * @module __tests__/integration/renderer/App.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +// Create mock functions for accomplish API +const mockSetOnboardingComplete = vi.fn(); +const mockLogEvent = vi.fn(); +const mockListTasks = vi.fn(); +const mockOnTaskStatusChange = vi.fn(); +const mockOnTaskUpdate = vi.fn(); +const mockGetTask = vi.fn(); + +// Mock accomplish API +const mockAccomplish = { + setOnboardingComplete: mockSetOnboardingComplete, + logEvent: mockLogEvent.mockResolvedValue(undefined), + listTasks: mockListTasks.mockResolvedValue([]), + onTaskStatusChange: mockOnTaskStatusChange.mockReturnValue(() => {}), + onTaskUpdate: mockOnTaskUpdate.mockReturnValue(() => {}), + getTask: mockGetTask.mockResolvedValue(null), + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module - always return true for isRunningInElectron for most tests +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, + isRunningInElectron: () => true, +})); + +// Mock analytics +vi.mock('@/lib/analytics', () => ({ + analytics: { + trackPageView: vi.fn(), + trackNewTask: vi.fn(), + trackOpenSettings: vi.fn(), + }, +})); + +// Mock framer-motion to simplify testing animations +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => { + const { initial, animate, exit, transition, variants, whileHover, ...domProps } = props; + return
{children}
; + }, + p: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => { + const { initial, animate, exit, transition, variants, ...domProps } = props; + return

{children}

; + }, + button: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => { + const { initial, animate, exit, transition, variants, whileHover, ...domProps } = props; + return ; + }, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Mock animation utilities +vi.mock('@/lib/animations', () => ({ + springs: { + bouncy: { type: 'spring', stiffness: 300 }, + gentle: { type: 'spring', stiffness: 200 }, + }, + variants: { + fadeUp: { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + }, + }, + staggerContainer: {}, + staggerItem: {}, +})); + +// Mock the task store +const mockLoadTasks = vi.fn(); +const mockReset = vi.fn(); +let mockStoreState = { + tasks: [], + currentTask: null, + isLoading: false, + loadTasks: mockLoadTasks, + reset: mockReset, + loadTaskById: vi.fn(), + updateTaskStatus: vi.fn(), + addTaskUpdate: vi.fn(), +}; + +vi.mock('@/stores/taskStore', () => ({ + useTaskStore: () => mockStoreState, +})); + +// Mock the Sidebar component +vi.mock('@/components/layout/Sidebar', () => ({ + default: () =>
Sidebar
, +})); + +// Mock the HomePage +vi.mock('@/pages/Home', () => ({ + default: () =>
Home Page Content
, +})); + +// Mock the ExecutionPage +vi.mock('@/pages/Execution', () => ({ + default: () =>
Execution Page Content
, +})); + +// Import App after all mocks are set up +import App from '@/App'; + +describe('App Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + mockStoreState = { + tasks: [], + currentTask: null, + isLoading: false, + loadTasks: mockLoadTasks, + reset: mockReset, + loadTaskById: vi.fn(), + updateTaskStatus: vi.fn(), + addTaskUpdate: vi.fn(), + }; + mockSetOnboardingComplete.mockResolvedValue(undefined); + }); + + // Helper to render App with router + const renderApp = (initialRoute = '/') => { + return render( + + + + ); + }; + + describe('router setup', () => { + it('should render sidebar in ready state', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); + }); + }); + + it('should render main content area', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + const main = document.querySelector('main'); + expect(main).toBeInTheDocument(); + }); + }); + + it('should render drag region for window dragging', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + const dragRegion = document.querySelector('.drag-region'); + expect(dragRegion).toBeInTheDocument(); + }); + }); + }); + + describe('route rendering - Home', () => { + it('should render home page at root route', async () => { + // Arrange & Act + renderApp('/'); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + it('should render home page content', async () => { + // Arrange & Act + renderApp('/'); + + // Assert + await waitFor(() => { + expect(screen.getByText('Home Page Content')).toBeInTheDocument(); + }); + }); + }); + + describe('route rendering - Execution', () => { + it('should render execution page at /execution/:id route', async () => { + // Arrange & Act + renderApp('/execution/task-123'); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('execution-page')).toBeInTheDocument(); + }); + }); + + it('should render execution page content', async () => { + // Arrange & Act + renderApp('/execution/task-123'); + + // Assert + await waitFor(() => { + expect(screen.getByText('Execution Page Content')).toBeInTheDocument(); + }); + }); + + it('should handle different task IDs', async () => { + // Arrange & Act + renderApp('/execution/different-task-456'); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('execution-page')).toBeInTheDocument(); + }); + }); + }); + + describe('route rendering - Fallback', () => { + it('should redirect unknown routes to home', async () => { + // Arrange & Act + renderApp('/unknown-route'); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + it('should redirect /history to home (since it is not defined)', async () => { + // Arrange & Act + renderApp('/history'); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + it('should redirect deeply nested unknown routes to home', async () => { + // Arrange & Act + renderApp('/some/deeply/nested/route'); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + }); + + describe('layout structure', () => { + it('should render with flex layout', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + const flexContainer = document.querySelector('.flex.h-screen'); + expect(flexContainer).toBeInTheDocument(); + }); + }); + + it('should prevent overflow on app container', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + const container = document.querySelector('.overflow-hidden'); + expect(container).toBeInTheDocument(); + }); + }); + + it('should render main content with flex-1 for proper sizing', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + const main = document.querySelector('main.flex-1'); + expect(main).toBeInTheDocument(); + }); + }); + }); + + describe('analytics tracking', () => { + it('should track page view on mount', async () => { + // Arrange + const { analytics } = await import('@/lib/analytics'); + + // Act + renderApp('/'); + + // Assert + await waitFor(() => { + expect(analytics.trackPageView).toHaveBeenCalledWith('/'); + }); + }); + + it('should track page view for execution route', async () => { + // Arrange + const { analytics } = await import('@/lib/analytics'); + + // Act + renderApp('/execution/task-123'); + + // Assert + await waitFor(() => { + expect(analytics.trackPageView).toHaveBeenCalledWith('/execution/task-123'); + }); + }); + }); + + describe('accessibility', () => { + it('should have main landmark element', async () => { + // Arrange & Act + renderApp(); + + // Assert + await waitFor(() => { + const main = screen.getByRole('main'); + expect(main).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx new file mode 100644 index 000000000..19a689328 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx @@ -0,0 +1,272 @@ +/** + * Integration tests for Header component + * Tests rendering and navigation elements + * @module __tests__/integration/renderer/components/Header.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Header from '@/components/layout/Header'; + +describe('Header Integration', () => { + describe('rendering', () => { + it('should render the header element', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const header = screen.getByRole('banner'); + expect(header).toBeInTheDocument(); + }); + + it('should render the logo/brand link', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const brandLink = screen.getByRole('link', { name: /openwork/i }); + expect(brandLink).toBeInTheDocument(); + expect(brandLink).toHaveAttribute('href', '/'); + }); + + it('should render the brand text', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + expect(screen.getByText('Openwork')).toBeInTheDocument(); + }); + }); + + describe('navigation elements', () => { + it('should render the navigation', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const nav = screen.getByRole('navigation'); + expect(nav).toBeInTheDocument(); + }); + + it('should render Home navigation link', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const homeLink = screen.getByRole('link', { name: /^home$/i }); + expect(homeLink).toBeInTheDocument(); + expect(homeLink).toHaveAttribute('href', '/'); + }); + + it('should render History navigation link', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const historyLink = screen.getByRole('link', { name: /history/i }); + expect(historyLink).toBeInTheDocument(); + expect(historyLink).toHaveAttribute('href', '/history'); + }); + + it('should render Settings navigation link', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const settingsLink = screen.getByRole('link', { name: /settings/i }); + expect(settingsLink).toBeInTheDocument(); + expect(settingsLink).toHaveAttribute('href', '/settings'); + }); + + it('should render all three navigation links', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const nav = screen.getByRole('navigation'); + const links = nav.querySelectorAll('a'); + expect(links).toHaveLength(3); + }); + }); + + describe('active state', () => { + it('should mark Home link as active when on home route', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const homeLink = screen.getByRole('link', { name: /^home$/i }); + expect(homeLink.className).toContain('nav-link-active'); + }); + + it('should mark History link as active when on history route', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const historyLink = screen.getByRole('link', { name: /history/i }); + expect(historyLink.className).toContain('nav-link-active'); + }); + + it('should mark Settings link as active when on settings route', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const settingsLink = screen.getByRole('link', { name: /settings/i }); + expect(settingsLink.className).toContain('nav-link-active'); + }); + + it('should not mark Home link as active when on other routes', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const homeLink = screen.getByRole('link', { name: /^home$/i }); + expect(homeLink.className).not.toContain('nav-link-active'); + }); + + it('should have nav-link class on all navigation links', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const homeLink = screen.getByRole('link', { name: /^home$/i }); + const historyLink = screen.getByRole('link', { name: /history/i }); + const settingsLink = screen.getByRole('link', { name: /settings/i }); + + expect(homeLink.className).toContain('nav-link'); + expect(historyLink.className).toContain('nav-link'); + expect(settingsLink.className).toContain('nav-link'); + }); + }); + + describe('layout and structure', () => { + it('should have drag region class for window dragging', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const header = screen.getByRole('banner'); + expect(header.className).toContain('drag-region'); + }); + + it('should have no-drag class on logo link', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const brandLink = screen.getByRole('link', { name: /openwork/i }); + expect(brandLink.className).toContain('no-drag'); + }); + + it('should have no-drag class on navigation', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const nav = screen.getByRole('navigation'); + expect(nav.className).toContain('no-drag'); + }); + + it('should render logo icon SVG', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert + const brandLink = screen.getByRole('link', { name: /openwork/i }); + const svg = brandLink.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + }); + + describe('deep routes', () => { + it('should not highlight any nav link on execution routes', () => { + // Arrange & Act + render( + +
+ + ); + + // Assert - None of the standard routes should be active + const homeLink = screen.getByRole('link', { name: /^home$/i }); + const historyLink = screen.getByRole('link', { name: /history/i }); + const settingsLink = screen.getByRole('link', { name: /settings/i }); + + expect(homeLink.className).not.toContain('nav-link-active'); + expect(historyLink.className).not.toContain('nav-link-active'); + expect(settingsLink.className).not.toContain('nav-link-active'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx new file mode 100644 index 000000000..b95408962 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx @@ -0,0 +1,854 @@ +/** + * Integration tests for SettingsDialog component + * Tests dialog rendering, API key management, model selection, and debug mode + * @module __tests__/integration/renderer/components/SettingsDialog.integration.test + * @vitest-environment jsdom + * + * NOTE: Many tests in this file are skipped because they were written for the old + * API key-based Settings UI. The SettingsDialog was redesigned to use a provider-based + * system with ProviderGrid and ProviderSettingsPanel components. + * + * The Settings functionality is covered by E2E tests in e2e/specs/settings.spec.ts. + * These integration tests should be rewritten to test the new provider-based UI. + * + * TODO: Rewrite tests for new provider-based Settings UI + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { ApiKeyConfig } from '@accomplish/shared'; + +// Mock analytics to prevent tracking calls +vi.mock('@/lib/analytics', () => ({ + analytics: { + trackToggleDebugMode: vi.fn(), + trackSelectModel: vi.fn(), + trackSaveApiKey: vi.fn(), + trackSelectProvider: vi.fn(), + }, +})); + +// Create mock functions for accomplish API +const mockGetApiKeys = vi.fn(); +const mockGetDebugMode = vi.fn(); +const mockGetVersion = vi.fn(); +const mockGetSelectedModel = vi.fn(); +const mockSetDebugMode = vi.fn(); +const mockSetSelectedModel = vi.fn(); +const mockAddApiKey = vi.fn(); +const mockRemoveApiKey = vi.fn(); +const mockValidateApiKeyForProvider = vi.fn(); + +// Mock accomplish API +const mockAccomplish = { + getApiKeys: mockGetApiKeys, + getDebugMode: mockGetDebugMode, + getVersion: mockGetVersion, + getSelectedModel: mockGetSelectedModel, + getOllamaConfig: vi.fn().mockResolvedValue(null), + setDebugMode: mockSetDebugMode, + setSelectedModel: mockSetSelectedModel, + addApiKey: mockAddApiKey, + removeApiKey: mockRemoveApiKey, + validateApiKeyForProvider: mockValidateApiKeyForProvider, + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +// Mock framer-motion to simplify testing animations +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => { + // Filter out motion-specific props + const { initial, animate, exit, transition, variants, whileHover, ...domProps } = props; + return
{children}
; + }, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Mock Radix Dialog to simplify testing +vi.mock('@radix-ui/react-dialog', () => ({ + Root: ({ children, open }: { children: React.ReactNode; open: boolean }) => ( + open ?
{children}
: null + ), + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + Overlay: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Content: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + Title: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +

{children}

+ ), + Close: ({ children }: { children: React.ReactNode }) => ( + + ), +})); + +// Need to import after mocks are set up +import SettingsDialog from '@/components/layout/SettingsDialog'; + +describe('SettingsDialog Integration', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + onApiKeySaved: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementations + mockGetApiKeys.mockResolvedValue([]); + mockGetDebugMode.mockResolvedValue(false); + mockGetVersion.mockResolvedValue('1.0.0'); + mockGetSelectedModel.mockResolvedValue({ provider: 'anthropic', model: 'anthropic/claude-opus-4-5' }); + mockSetDebugMode.mockResolvedValue(undefined); + mockSetSelectedModel.mockResolvedValue(undefined); + mockValidateApiKeyForProvider.mockResolvedValue({ valid: true }); + mockAddApiKey.mockResolvedValue({ id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }); + mockRemoveApiKey.mockResolvedValue(undefined); + }); + + describe('dialog rendering', () => { + it('should render dialog when open is true', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + }); + + it('should not render dialog when open is false', () => { + // Arrange & Act + render(); + + // Assert + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should render dialog title', async () => { + // Arrange & Act + render(); + + // Assert - new SettingsDialog uses "Set up Openwork" as title + await waitFor(() => { + expect(screen.getByText('Set up Openwork')).toBeInTheDocument(); + }); + }); + + it('should fetch initial data on open', async () => { + // Arrange & Act + render(); + + // Assert - new provider-based SettingsDialog fetches provider settings + await waitFor(() => { + expect(mockAccomplish.getProviderSettings).toHaveBeenCalled(); + }); + }); + + it('should not render dialog content when open is false', () => { + // Arrange & Act + render(); + + // Assert - Dialog root should not be in document when closed + expect(screen.queryByTestId('dialog-root')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('provider active state', () => { + /** + * Bug test: Newly connected ready provider should become active + * + * Bug: When connecting a new provider that is immediately "ready" (has a default + * model auto-selected), it should become the active provider. However, the bug + * caused the green active indicator to stay on the previously active provider. + * + * Root cause: handleConnect only called setActiveProvider when NO provider was + * active (!settings?.activeProviderId). It should call setActiveProvider when + * the new provider is ready, regardless of existing active provider. + * + * This test verifies that when Provider B connects with a default model while + * Provider A is already active, Provider B becomes the new active provider. + * + * Test approach: This is a unit test of the handleConnect logic in SettingsDialog. + * We check that setActiveProvider is called when a ready provider connects, + * even when another provider is already active. The actual UI flow requires + * provider forms which are complex to mock, so we test the observable behavior + * through the hook's setActiveProvider being called. + */ + it('should call setActiveProvider when a ready provider connects (regression test)', async () => { + // This test documents the expected behavior: + // When handleConnect receives a provider that is "ready" (has selectedModelId), + // it should call setActiveProvider with that provider's ID, regardless of + // whether activeProviderId already has a value. + // + // The bug is in SettingsDialog.tsx handleConnect: + // BUGGY: if (!settings?.activeProviderId) { setActiveProvider(...) } + // CORRECT: if (isProviderReady(provider)) { setActiveProvider(...) } + // + // Since the full UI flow is difficult to test in isolation, we document + // the expected behavior here and rely on E2E tests for full validation. + + // Initial state: anthropic is connected and active + mockAccomplish.getProviderSettings = vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'anthropic/claude-haiku-4-5', + credentials: { type: 'api-key', apiKeyPrefix: 'sk-ant-...' }, + lastConnectedAt: new Date().toISOString(), + }, + }, + debugMode: false, + }); + + render(); + + // Wait for dialog to load with anthropic as active + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + // Verify anthropic card has green background (is active) + const anthropicCard = screen.getByTestId('provider-card-anthropic'); + expect(anthropicCard.className).toContain('bg-[#e9f7e7]'); + }); + + // Verify the initial state: anthropic is active + // This confirms the test setup is correct + expect(mockAccomplish.getProviderSettings).toHaveBeenCalled(); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + // TODO: Rewrite these tests for the new ProviderGrid/ProviderSettingsPanel UI + describe.skip('API key section', () => { + it('should render API key section title', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Bring Your Own Model/API Key')).toBeInTheDocument(); + }); + }); + + it('should render provider selection buttons', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Anthropic')).toBeInTheDocument(); + expect(screen.getByText('OpenAI')).toBeInTheDocument(); + expect(screen.getByText('Google AI')).toBeInTheDocument(); + expect(screen.getByText('xAI (Grok)')).toBeInTheDocument(); + }); + }); + + it('should render API key input field', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + const input = screen.getByPlaceholderText('sk-ant-...'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('type', 'password'); + }); + }); + + it('should render Save API Key button', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByRole('button', { name: /save api key/i })).toBeInTheDocument(); + }); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + describe.skip('provider selection', () => { + it('should change provider when button is clicked', async () => { + // Arrange + render(); + + // Act + await waitFor(() => { + expect(screen.getByText('Google AI')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Google AI')); + + // Assert + await waitFor(() => { + expect(screen.getByPlaceholderText('AIza...')).toBeInTheDocument(); + }); + }); + + it('should update input placeholder when provider changes', async () => { + // Arrange + render(); + + // Act - Click Google AI provider + await waitFor(() => { + expect(screen.getByText('Google AI')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Google AI')); + + // Assert + await waitFor(() => { + expect(screen.getByPlaceholderText('AIza...')).toBeInTheDocument(); + }); + }); + + it('should highlight selected provider', async () => { + // Arrange + render(); + + // Assert - Anthropic is selected by default and should have highlight class + await waitFor(() => { + const anthropicButton = screen.getByText('Anthropic').closest('button'); + expect(anthropicButton?.className).toContain('border-primary'); + }); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + describe.skip('API key input and saving', () => { + it('should show error when saving empty API key', async () => { + // Arrange + render(); + + // Act + await waitFor(() => { + expect(screen.getByRole('button', { name: /save api key/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + await waitFor(() => { + expect(screen.getByText('Please enter an API key.')).toBeInTheDocument(); + }); + }); + + it('should show error when API key format is invalid', async () => { + // Arrange + render(); + + // Act + await waitFor(() => { + expect(screen.getByPlaceholderText('sk-ant-...')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByPlaceholderText('sk-ant-...'), { target: { value: 'invalid-key' } }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + await waitFor(() => { + expect(screen.getByText(/invalid api key format/i)).toBeInTheDocument(); + }); + }); + + it('should validate and save valid API key', async () => { + // Arrange + mockValidateApiKeyForProvider.mockResolvedValue({ valid: true }); + mockAddApiKey.mockResolvedValue({ id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }); + render(); + + // Act + await waitFor(() => { + expect(screen.getByPlaceholderText('sk-ant-...')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByPlaceholderText('sk-ant-...'), { target: { value: 'sk-ant-test123' } }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + await waitFor(() => { + expect(mockValidateApiKeyForProvider).toHaveBeenCalledWith('anthropic', 'sk-ant-test123'); + expect(mockAddApiKey).toHaveBeenCalledWith('anthropic', 'sk-ant-test123'); + }); + }); + + it('should show error when API key validation fails', async () => { + // Arrange + mockValidateApiKeyForProvider.mockResolvedValue({ valid: false, error: 'Invalid API key' }); + render(); + + // Act + await waitFor(() => { + expect(screen.getByPlaceholderText('sk-ant-...')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByPlaceholderText('sk-ant-...'), { target: { value: 'sk-ant-invalid' } }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + await waitFor(() => { + expect(screen.getByText('Invalid API key')).toBeInTheDocument(); + }); + }); + + it('should show success message after saving API key', async () => { + // Arrange + mockValidateApiKeyForProvider.mockResolvedValue({ valid: true }); + mockAddApiKey.mockResolvedValue({ id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }); + render(); + + // Act + await waitFor(() => { + expect(screen.getByPlaceholderText('sk-ant-...')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByPlaceholderText('sk-ant-...'), { target: { value: 'sk-ant-valid123' } }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + await waitFor(() => { + expect(screen.getByText(/anthropic api key saved securely/i)).toBeInTheDocument(); + }); + }); + + it('should call onApiKeySaved callback after saving', async () => { + // Arrange + const onApiKeySaved = vi.fn(); + mockValidateApiKeyForProvider.mockResolvedValue({ valid: true }); + mockAddApiKey.mockResolvedValue({ id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }); + render(); + + // Act + await waitFor(() => { + expect(screen.getByPlaceholderText('sk-ant-...')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByPlaceholderText('sk-ant-...'), { target: { value: 'sk-ant-valid123' } }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + await waitFor(() => { + expect(onApiKeySaved).toHaveBeenCalled(); + }); + }); + + it('should show Saving... while saving is in progress', async () => { + // Arrange + mockValidateApiKeyForProvider.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ valid: true }), 100)) + ); + render(); + + // Act + await waitFor(() => { + expect(screen.getByPlaceholderText('sk-ant-...')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByPlaceholderText('sk-ant-...'), { target: { value: 'sk-ant-valid123' } }); + fireEvent.click(screen.getByRole('button', { name: /save api key/i })); + + // Assert + expect(screen.getByText('Saving...')).toBeInTheDocument(); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + describe.skip('saved keys display', () => { + it('should render saved API keys', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-abc...' }, + { id: 'key-2', provider: 'openai', keyPrefix: 'sk-xyz...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Saved Keys')).toBeInTheDocument(); + expect(screen.getByText('sk-ant-abc...')).toBeInTheDocument(); + expect(screen.getByText('sk-xyz...')).toBeInTheDocument(); + }); + }); + + it('should show delete button for each saved key', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-abc...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Assert + await waitFor(() => { + expect(screen.getByTitle('Remove API key')).toBeInTheDocument(); + }); + }); + + it('should delete API key when delete button is clicked and confirmed', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-abc...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Act - Click delete button to show confirmation + await waitFor(() => { + expect(screen.getByTitle('Remove API key')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTitle('Remove API key')); + + // Act - Confirm deletion by clicking Yes + await waitFor(() => { + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /yes/i })); + + // Assert + await waitFor(() => { + expect(mockRemoveApiKey).toHaveBeenCalledWith('key-1'); + }); + }); + + it('should not delete API key when confirmation is cancelled', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-abc...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Act - Click delete button to show confirmation + await waitFor(() => { + expect(screen.getByTitle('Remove API key')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTitle('Remove API key')); + + // Act - Cancel by clicking No + await waitFor(() => { + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /no/i })); + + // Assert - Should not delete, confirmation should be hidden + expect(mockRemoveApiKey).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByText('Are you sure?')).not.toBeInTheDocument(); + }); + }); + + it('should show loading skeleton while fetching keys', async () => { + // Arrange + mockGetApiKeys.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 500)) + ); + render(); + + // Assert - Check for skeleton animation + await waitFor(() => { + const skeletons = document.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThan(0); + }); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + describe.skip('model selection', () => { + it('should render Model section', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Model')).toBeInTheDocument(); + }); + }); + + it('should render model selection dropdown', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Assert + await waitFor(() => { + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + }); + }); + + it('should show model options grouped by provider', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Assert - Check for Anthropic group + await waitFor(() => { + const optgroups = document.querySelectorAll('optgroup'); + expect(optgroups.length).toBeGreaterThan(0); + }); + }); + + it('should disable models without API keys', async () => { + // Arrange - No Google AI API key + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Assert + await waitFor(() => { + const option = screen.getByRole('option', { name: /gemini 3 pro \(no api key\)/i }); + expect(option).toBeDisabled(); + }); + }); + + it('should call setSelectedModel when model is changed', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Act + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'anthropic/claude-sonnet-4-5' } }); + + // Assert + await waitFor(() => { + expect(mockSetSelectedModel).toHaveBeenCalledWith({ + provider: 'anthropic', + model: 'anthropic/claude-sonnet-4-5', + }); + }); + }); + + it('should show model updated message after selection', async () => { + // Arrange + const savedKeys: ApiKeyConfig[] = [ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }, + ]; + mockGetApiKeys.mockResolvedValue(savedKeys); + render(); + + // Act + await waitFor(() => { + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'anthropic/claude-sonnet-4-5' } }); + + // Assert + await waitFor(() => { + expect(screen.getByText(/model updated to/i)).toBeInTheDocument(); + }); + }); + + it('should show warning when selected model has no API key', async () => { + // Arrange - Selected Google AI model but no Google AI key + mockGetSelectedModel.mockResolvedValue({ provider: 'google', model: 'google/gemini-3-pro-preview' }); + mockGetApiKeys.mockResolvedValue([ + { id: 'key-1', provider: 'anthropic', keyPrefix: 'sk-ant-...' }, + ]); + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText(/no api key configured for google/i)).toBeInTheDocument(); + }); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + describe.skip('debug mode toggle', () => { + it('should render Developer section', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Developer')).toBeInTheDocument(); + }); + }); + + it('should render Debug Mode toggle', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Debug Mode')).toBeInTheDocument(); + }); + }); + + it('should show debug mode as disabled initially', async () => { + // Arrange + mockGetDebugMode.mockResolvedValue(false); + render(); + + // Assert + await waitFor(() => { + const toggle = screen.getByRole('button', { name: '' }); + expect(toggle.className).toContain('bg-muted'); + }); + }); + + it('should toggle debug mode when clicked', async () => { + // Arrange + mockGetDebugMode.mockResolvedValue(false); + render(); + + // Find the toggle button in the Developer section + await waitFor(() => { + expect(screen.getByText('Debug Mode')).toBeInTheDocument(); + }); + + // Act - Find toggle by its appearance (the switch button) + const developerSection = screen.getByText('Debug Mode').closest('section'); + const toggleButton = developerSection?.querySelector('button[class*="rounded-full"]'); + if (toggleButton) { + fireEvent.click(toggleButton); + } + + // Assert + await waitFor(() => { + expect(mockSetDebugMode).toHaveBeenCalledWith(true); + }); + }); + + it('should show debug mode warning when enabled', async () => { + // Arrange + mockGetDebugMode.mockResolvedValue(true); + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText(/debug mode is enabled/i)).toBeInTheDocument(); + }); + }); + + it('should show loading skeleton while fetching debug setting', async () => { + // Arrange + mockGetDebugMode.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(false), 500)) + ); + render(); + + // Assert - Check for skeleton animation near debug toggle + await waitFor(() => { + const skeletons = document.querySelectorAll('.animate-pulse'); + expect(skeletons.length).toBeGreaterThan(0); + }); + }); + + it('should revert toggle state on save error', async () => { + // Arrange + mockGetDebugMode.mockResolvedValue(false); + mockSetDebugMode.mockRejectedValue(new Error('Save failed')); + render(); + + await waitFor(() => { + expect(screen.getByText('Debug Mode')).toBeInTheDocument(); + }); + + // Act + const developerSection = screen.getByText('Debug Mode').closest('section'); + const toggleButton = developerSection?.querySelector('button[class*="rounded-full"]'); + if (toggleButton) { + fireEvent.click(toggleButton); + } + + // Assert - Mock should have been called and error handled + await waitFor(() => { + expect(mockSetDebugMode).toHaveBeenCalled(); + }); + }); + }); + + // SKIP: Old UI tests - SettingsDialog was redesigned with provider-based system + describe.skip('about section', () => { + it('should render About section', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('About')).toBeInTheDocument(); + }); + }); + + it('should render app name', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Openwork')).toBeInTheDocument(); + }); + }); + + it('should render app version', async () => { + // Arrange + mockGetVersion.mockResolvedValue('2.0.0'); + render(); + + // Assert + await waitFor(() => { + expect(screen.getByText('Version 2.0.0')).toBeInTheDocument(); + }); + }); + + it('should render app logo', async () => { + // Arrange & Act + render(); + + // Assert + await waitFor(() => { + const logo = screen.getByRole('img', { name: /openwork/i }); + expect(logo).toBeInTheDocument(); + }); + }); + + it('should show default version when fetch fails', async () => { + // Arrange + mockGetVersion.mockRejectedValue(new Error('Fetch failed')); + render(); + + // Assert - should show error instead of fallback version + await waitFor(() => { + expect(screen.getByText('Version Error: unavailable')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx new file mode 100644 index 000000000..8a7a3a959 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx @@ -0,0 +1,522 @@ +/** + * Integration tests for Sidebar component + * Tests rendering with conversations, conversation selection, and settings + * @module __tests__/integration/renderer/components/Sidebar.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { Task, TaskStatus } from '@accomplish/shared'; + +// Mock analytics to prevent tracking calls +vi.mock('@/lib/analytics', () => ({ + analytics: { + trackNewTask: vi.fn(), + trackOpenSettings: vi.fn(), + }, +})); + +// Create mock functions outside of mock factory +const mockLoadTasks = vi.fn(); +const mockUpdateTaskStatus = vi.fn(); +const mockAddTaskUpdate = vi.fn(); +const mockListTasks = vi.fn(); +const mockOnTaskStatusChange = vi.fn(); +const mockOnTaskUpdate = vi.fn(); + +// Helper to create mock tasks +function createMockTask( + id: string, + prompt: string = 'Test task', + status: TaskStatus = 'completed' +): Task { + return { + id, + prompt, + status, + messages: [], + createdAt: new Date().toISOString(), + }; +} + +// Mock accomplish API +const mockAccomplish = { + listTasks: mockListTasks.mockResolvedValue([]), + onTaskStatusChange: mockOnTaskStatusChange.mockReturnValue(() => {}), + onTaskUpdate: mockOnTaskUpdate.mockReturnValue(() => {}), + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +// Create a store state holder for testing +let mockStoreState = { + tasks: [] as Task[], + loadTasks: mockLoadTasks, + updateTaskStatus: mockUpdateTaskStatus, + addTaskUpdate: mockAddTaskUpdate, +}; + +// Mock the task store +vi.mock('@/stores/taskStore', () => ({ + useTaskStore: () => mockStoreState, +})); + +// Mock the SettingsDialog to simplify testing +vi.mock('@/components/layout/SettingsDialog', () => ({ + default: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ( + open ? ( +
+ +
+ ) : null + ), +})); + +// Mock framer-motion to simplify testing animations +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + button: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( + + ), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Need to import after mocks are set up +import Sidebar from '@/components/layout/Sidebar'; + +describe('Sidebar Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + mockStoreState = { + tasks: [], + loadTasks: mockLoadTasks, + updateTaskStatus: mockUpdateTaskStatus, + addTaskUpdate: mockAddTaskUpdate, + }; + }); + + describe('rendering with no conversations', () => { + it('should render the sidebar container', () => { + // Arrange & Act + render( + + + + ); + + // Assert - sidebar should be present (260px width) + const sidebar = document.querySelector('.w-\\[260px\\]'); + expect(sidebar).toBeInTheDocument(); + }); + + it('should render New Task button', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const newTaskButton = screen.getByRole('button', { name: /new task/i }); + expect(newTaskButton).toBeInTheDocument(); + }); + + it('should show empty state message when no conversations', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(screen.getByText(/no conversations yet/i)).toBeInTheDocument(); + }); + + it('should render Settings button', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const settingsButton = screen.getByRole('button', { name: /settings/i }); + expect(settingsButton).toBeInTheDocument(); + }); + + it('should render logo image', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const logo = screen.getByRole('img', { name: /openwork/i }); + expect(logo).toBeInTheDocument(); + }); + + it('should call loadTasks on mount', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(mockLoadTasks).toHaveBeenCalled(); + }); + }); + + describe('rendering with conversations', () => { + it('should render conversation list when tasks exist', () => { + // Arrange + const tasks = [ + createMockTask('task-1', 'Check my email inbox'), + createMockTask('task-2', 'Review calendar'), + ]; + mockStoreState.tasks = tasks; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Check my email inbox')).toBeInTheDocument(); + expect(screen.getByText('Review calendar')).toBeInTheDocument(); + }); + + it('should not show empty state when tasks exist', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'A task')]; + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByText(/no conversations yet/i)).not.toBeInTheDocument(); + }); + + it('should render all tasks in the list', () => { + // Arrange + const tasks = [ + createMockTask('task-1', 'First task'), + createMockTask('task-2', 'Second task'), + createMockTask('task-3', 'Third task'), + ]; + mockStoreState.tasks = tasks; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('First task')).toBeInTheDocument(); + expect(screen.getByText('Second task')).toBeInTheDocument(); + expect(screen.getByText('Third task')).toBeInTheDocument(); + }); + + it('should show running indicator for running tasks', () => { + // Arrange + const tasks = [ + createMockTask('task-1', 'Running task', 'running'), + ]; + mockStoreState.tasks = tasks; + + // Act + render( + + + + ); + + // Assert - Check for spinning loader icon + const taskItem = screen.getByText('Running task').closest('button'); + const spinner = taskItem?.querySelector('.animate-spin-ccw'); + expect(spinner).toBeInTheDocument(); + }); + + it('should show completed indicator for completed tasks', () => { + // Arrange + const tasks = [ + createMockTask('task-1', 'Completed task', 'completed'), + ]; + mockStoreState.tasks = tasks; + + // Act + render( + + + + ); + + // Assert - Check for checkmark icon (CheckCircle2) + const taskItem = screen.getByText('Completed task').closest('button'); + const checkIcon = taskItem?.querySelector('svg'); + expect(checkIcon).toBeInTheDocument(); + }); + }); + + describe('conversation selection', () => { + it('should render conversation items as clickable buttons', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Clickable task')]; + + // Act + render( + + + + ); + + // Assert + const taskButton = screen.getByText('Clickable task').closest('button'); + expect(taskButton).toBeInTheDocument(); + expect(taskButton?.tagName).toBe('BUTTON'); + }); + + it('should navigate to execution page when conversation is clicked', async () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-123', 'Navigate task')]; + + // Act + render( + + + + ); + + const taskButton = screen.getByText('Navigate task').closest('button'); + if (taskButton) { + fireEvent.click(taskButton); + } + + // Assert - Check that the link navigates correctly + // In real scenario, this would change the route + await waitFor(() => { + expect(taskButton).toBeInTheDocument(); + }); + }); + + it('should highlight active conversation', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-123', 'Active task')]; + + // Act + render( + + + + ); + + // Assert + const taskButton = screen.getByText('Active task').closest('button'); + expect(taskButton?.className).toContain('bg-accent'); + }); + + it('should not highlight inactive conversations', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'First task'), + createMockTask('task-2', 'Second task'), + ]; + + // Act + render( + + + + ); + + // Assert - Second task should not be highlighted with the active class + // The component uses 'bg-accent' class for active state, while hover state uses 'hover:bg-accent' + const secondTaskButton = screen.getByText('Second task').closest('button'); + const classNames = (secondTaskButton?.className || '').split(' '); + // Filter to find only exact 'bg-accent' class, not 'hover:bg-accent' + const hasBgAccent = classNames.some(c => c === 'bg-accent'); + expect(hasBgAccent).toBe(false); + }); + }); + + describe('new task button', () => { + it('should navigate to home when New Task is clicked', async () => { + // Arrange + render( + + + + ); + + // Act + const newTaskButton = screen.getByRole('button', { name: /new task/i }); + fireEvent.click(newTaskButton); + + // Assert - Button should be clickable (navigation handled by React Router) + await waitFor(() => { + expect(newTaskButton).toBeInTheDocument(); + }); + }); + + it('should display MessageSquarePlus icon in New Task button', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const newTaskButton = screen.getByRole('button', { name: /new task/i }); + const icon = newTaskButton.querySelector('svg'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe('settings dialog', () => { + it('should open settings dialog when Settings button is clicked', async () => { + // Arrange + render( + + + + ); + + // Act + const settingsButton = screen.getByRole('button', { name: /settings/i }); + fireEvent.click(settingsButton); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('settings-dialog')).toBeInTheDocument(); + }); + }); + + it('should close settings dialog when close is triggered', async () => { + // Arrange + render( + + + + ); + + // Act - Open dialog + const settingsButton = screen.getByRole('button', { name: /settings/i }); + fireEvent.click(settingsButton); + + await waitFor(() => { + expect(screen.getByTestId('settings-dialog')).toBeInTheDocument(); + }); + + // Act - Close dialog + const closeButton = screen.getByRole('button', { name: /close settings/i }); + fireEvent.click(closeButton); + + // Assert + await waitFor(() => { + expect(screen.queryByTestId('settings-dialog')).not.toBeInTheDocument(); + }); + }); + }); + + describe('event subscriptions', () => { + it('should subscribe to task status changes on mount', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(mockOnTaskStatusChange).toHaveBeenCalled(); + }); + + it('should subscribe to task updates on mount', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(mockOnTaskUpdate).toHaveBeenCalled(); + }); + }); + + describe('layout structure', () => { + it('should render border between sections', () => { + // Arrange & Act + render( + + + + ); + + // Assert - Check for border classes + const sidebar = document.querySelector('.w-\\[260px\\]'); + expect(sidebar?.className).toContain('border-r'); + }); + + it('should render with correct height for full screen', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const sidebar = document.querySelector('.h-screen'); + expect(sidebar).toBeInTheDocument(); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx new file mode 100644 index 000000000..8e82f31fc --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx @@ -0,0 +1,487 @@ +/** + * Integration tests for StreamingText component and useStreamingState hook + * Tests text streaming animation, completion state, and different content types + * @module __tests__/integration/renderer/components/StreamingText.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; +import { StreamingText, useStreamingState } from '@/components/ui/streaming-text'; + +describe('StreamingText Integration', () => { + describe('basic rendering', () => { + it('should render with container div', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('should render full text when isComplete is true', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('Complete text'); + }); + + it('should render empty initially when not complete', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert - Initially empty + expect(screen.getByTestId('content')).toHaveTextContent(''); + }); + + it('should apply custom className', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + const container = document.querySelector('.custom-class'); + expect(container).toBeInTheDocument(); + }); + }); + + describe('text streaming animation', () => { + it('should start with zero characters when streaming', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent(''); + }); + }); + + describe('completion state', () => { + it('should show full text immediately when isComplete is true', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('Immediate complete'); + }); + + it('should stop streaming when isComplete changes to true', () => { + // Arrange + const { rerender } = render( + + {(text) => {text}} + + ); + + // Act - Complete immediately + rerender( + + {(text) => {text}} + + ); + + // Assert - Should immediately show full text + expect(screen.getByTestId('content')).toHaveTextContent('Partial text'); + }); + + it('should not call onComplete when isComplete is initially true', () => { + // Arrange + const onComplete = vi.fn(); + + // Act + render( + + {(text) => {text}} + + ); + + // Assert - onComplete should NOT be called for already complete text + expect(onComplete).not.toHaveBeenCalled(); + }); + }); + + describe('cursor indicator', () => { + it('should show cursor while streaming', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + const cursor = document.querySelector('.animate-pulse'); + expect(cursor).toBeInTheDocument(); + }); + + it('should hide cursor when streaming is complete', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + const cursor = document.querySelector('.animate-pulse'); + expect(cursor).not.toBeInTheDocument(); + }); + }); + + describe('different content types', () => { + it('should handle plain text content', () => { + // Arrange & Act + render( + + {(text) =>

{text}

} +
+ ); + + // Assert + expect(screen.getByText('Plain text content')).toBeInTheDocument(); + }); + + it('should handle markdown-style text', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('**Bold** and *italic* text'); + }); + + it('should handle code content', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('const x = 42;'); + }); + + it('should handle multiline content', () => { + // Arrange + const multilineText = `Line 1 +Line 2 +Line 3`; + + // Act + render( + + {(text) =>
{text}
} +
+ ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('Line 1'); + expect(screen.getByTestId('content')).toHaveTextContent('Line 2'); + expect(screen.getByTestId('content')).toHaveTextContent('Line 3'); + }); + + it('should handle empty text', () => { + // Arrange & Act + render( + + {(text) => {text || 'empty'}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('empty'); + }); + + it('should handle special characters', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('Special chars: @#$%^&*()'); + }); + + it('should handle unicode characters', () => { + // Arrange & Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content')).toHaveTextContent('Unicode: Hello World'); + }); + + it('should handle long text content', () => { + // Arrange + const longText = 'A'.repeat(1000); + + // Act + render( + + {(text) => {text}} + + ); + + // Assert + expect(screen.getByTestId('content').textContent?.length).toBe(1000); + }); + }); + + describe('render prop flexibility', () => { + it('should pass displayed text to children render prop', () => { + // Arrange + const renderSpy = vi.fn((text: string) => {text}); + + // Act + render( + + {renderSpy} + + ); + + // Assert + expect(renderSpy).toHaveBeenCalledWith('Test'); + }); + + it('should allow custom rendering of text', () => { + // Arrange & Act + render( + + {(text) => ( +
+ {text.toUpperCase()} +
+ )} +
+ ); + + // Assert + expect(screen.getByTestId('custom-render')).toHaveTextContent('CUSTOM'); + }); + + it('should allow wrapping text in complex markup', () => { + // Arrange & Act + render( + + {(text) => ( +
+
Header
+

{text}

+
Footer
+
+ )} +
+ ); + + // Assert + expect(screen.getByTestId('body')).toHaveTextContent('Wrapped'); + }); + }); +}); + +describe('useStreamingState Hook', () => { + describe('initial state', () => { + it('should return shouldStream as true for latest running assistant message', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('msg-1', true, true) + ); + + // Assert + expect(result.current.shouldStream).toBe(true); + }); + + it('should return shouldStream as false when not latest assistant message', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('msg-1', false, true) + ); + + // Assert + expect(result.current.shouldStream).toBe(false); + }); + + it('should return shouldStream as false when task not running', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('msg-1', true, false) + ); + + // Assert + expect(result.current.shouldStream).toBe(false); + }); + + it('should return isComplete as opposite of shouldStream', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('msg-1', true, true) + ); + + // Assert + expect(result.current.isComplete).toBe(false); + }); + }); + + describe('streaming completion', () => { + it('should provide onComplete callback', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('msg-1', true, true) + ); + + // Assert + expect(typeof result.current.onComplete).toBe('function'); + }); + + it('should mark as complete after onComplete is called', () => { + // Arrange + const { result, rerender } = renderHook(() => + useStreamingState('msg-1', true, true) + ); + + // Act + act(() => { + result.current.onComplete(); + }); + + // Trigger re-render + rerender(); + + // Assert + expect(result.current.shouldStream).toBe(false); + expect(result.current.isComplete).toBe(true); + }); + }); + + describe('message ID changes', () => { + it('should reset streaming state when message ID changes', () => { + // Arrange + const { result, rerender } = renderHook( + ({ messageId }) => useStreamingState(messageId, true, true), + { initialProps: { messageId: 'msg-1' } } + ); + + // Act - Complete streaming + act(() => { + result.current.onComplete(); + }); + + // Change message ID + rerender({ messageId: 'msg-2' }); + + // Assert - Should be streaming again + expect(result.current.shouldStream).toBe(true); + }); + }); + + describe('task running state changes', () => { + it('should stop streaming when task stops running', () => { + // Arrange + const { result, rerender } = renderHook( + ({ isRunning }) => useStreamingState('msg-1', true, isRunning), + { initialProps: { isRunning: true } } + ); + + expect(result.current.shouldStream).toBe(true); + + // Act - Stop task + rerender({ isRunning: false }); + + // Assert + expect(result.current.shouldStream).toBe(false); + expect(result.current.isComplete).toBe(true); + }); + }); + + describe('latest message changes', () => { + it('should stop streaming when no longer latest message', () => { + // Arrange + const { result, rerender } = renderHook( + ({ isLatest }) => useStreamingState('msg-1', isLatest, true), + { initialProps: { isLatest: true } } + ); + + expect(result.current.shouldStream).toBe(true); + + // Act - No longer latest + rerender({ isLatest: false }); + + // Assert + expect(result.current.shouldStream).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle all flags being false', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('msg-1', false, false) + ); + + // Assert + expect(result.current.shouldStream).toBe(false); + expect(result.current.isComplete).toBe(true); + }); + + it('should handle rapid state changes', () => { + // Arrange + const { result, rerender } = renderHook( + ({ isLatest, isRunning }) => + useStreamingState('msg-1', isLatest, isRunning), + { initialProps: { isLatest: true, isRunning: true } } + ); + + // Act - Rapid changes + for (let i = 0; i < 10; i++) { + rerender({ isLatest: i % 2 === 0, isRunning: i % 3 === 0 }); + } + + // Assert - Should be in consistent state + expect(typeof result.current.shouldStream).toBe('boolean'); + expect(typeof result.current.isComplete).toBe('boolean'); + }); + + it('should handle empty message ID', () => { + // Arrange & Act + const { result } = renderHook(() => + useStreamingState('', true, true) + ); + + // Assert - Should still work + expect(result.current.shouldStream).toBe(true); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx new file mode 100644 index 000000000..4ac675448 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx @@ -0,0 +1,791 @@ +/** + * Integration tests for TaskHistory component + * Tests task list rendering, selection, deletion, and history clearing + * @module __tests__/integration/renderer/components/TaskHistory.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { Task, TaskStatus } from '@accomplish/shared'; + +// Create mock functions for task store +const mockLoadTasks = vi.fn(); +const mockDeleteTask = vi.fn(); +const mockClearHistory = vi.fn(); + +// Create a store state holder for testing +let mockStoreState = { + tasks: [] as Task[], + loadTasks: mockLoadTasks, + deleteTask: mockDeleteTask, + clearHistory: mockClearHistory, +}; + +// Mock the task store +vi.mock('@/stores/taskStore', () => ({ + useTaskStore: () => mockStoreState, +})); + +// Helper to create mock tasks +function createMockTask( + id: string, + prompt: string = 'Test task', + status: TaskStatus = 'completed', + createdAt?: string, + messageCount: number = 0 +): Task { + return { + id, + prompt, + status, + messages: Array(messageCount).fill({ + id: 'msg-1', + type: 'assistant', + content: 'Test message', + timestamp: new Date().toISOString(), + }), + createdAt: createdAt || new Date().toISOString(), + }; +} + +// Need to import after mocks are set up +import TaskHistory from '@/components/history/TaskHistory'; + +describe('TaskHistory Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + mockStoreState = { + tasks: [], + loadTasks: mockLoadTasks, + deleteTask: mockDeleteTask, + clearHistory: mockClearHistory, + }; + // Mock window.confirm + vi.spyOn(window, 'confirm').mockImplementation(() => true); + }); + + describe('empty state rendering', () => { + it('should render empty state when no tasks exist', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(screen.getByText(/no tasks yet/i)).toBeInTheDocument(); + }); + + it('should render helpful message in empty state', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(screen.getByText(/start by describing what you want to accomplish/i)).toBeInTheDocument(); + }); + + it('should not render task list in empty state', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const taskItems = document.querySelectorAll('[class*="rounded-card"]'); + expect(taskItems.length).toBe(0); + }); + + it('should not render Clear all button in empty state', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(screen.queryByText(/clear all/i)).not.toBeInTheDocument(); + }); + }); + + describe('task list rendering', () => { + it('should render task list when tasks exist', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Send email to John'), + createMockTask('task-2', 'Create report'), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Send email to John')).toBeInTheDocument(); + expect(screen.getByText('Create report')).toBeInTheDocument(); + }); + + it('should render Recent Tasks title when showTitle is true', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Recent Tasks')).toBeInTheDocument(); + }); + + it('should not render title when showTitle is false', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByText('Recent Tasks')).not.toBeInTheDocument(); + }); + + it('should render task status indicator', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'My test task', 'completed')]; + + // Act + render( + + + + ); + + // Assert - Status label appears in the meta text + const metaText = screen.getByText(/Completed \u00B7/); + expect(metaText).toBeInTheDocument(); + }); + + it('should render message count for each task', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Task with messages', 'completed', undefined, 5)]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/5 messages/i)).toBeInTheDocument(); + }); + + it('should call loadTasks on mount', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(mockLoadTasks).toHaveBeenCalled(); + }); + }); + + describe('task status indicators', () => { + it('should show green indicator for completed tasks', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Completed task', 'completed')]; + + // Act + render( + + + + ); + + // Assert + const indicator = document.querySelector('.bg-success'); + expect(indicator).toBeInTheDocument(); + }); + + it('should show blue indicator for running tasks', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Running task', 'running')]; + + // Act + render( + + + + ); + + // Assert + const indicator = document.querySelector('.bg-accent-blue'); + expect(indicator).toBeInTheDocument(); + }); + + it('should show red indicator for failed tasks', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Failed task', 'failed')]; + + // Act + render( + + + + ); + + // Assert + const indicator = document.querySelector('.bg-danger'); + expect(indicator).toBeInTheDocument(); + }); + + it('should show grey indicator for cancelled tasks', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Cancelled task', 'cancelled')]; + + // Act + render( + + + + ); + + // Assert + const indicator = document.querySelector('.bg-text-muted'); + expect(indicator).toBeInTheDocument(); + }); + + it('should show yellow indicator for pending tasks', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Pending task', 'pending')]; + + // Act + render( + + + + ); + + // Assert + const indicator = document.querySelector('.bg-warning'); + expect(indicator).toBeInTheDocument(); + }); + + it('should show yellow indicator for waiting permission tasks', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'My test task', 'waiting_permission')]; + + // Act + render( + + + + ); + + // Assert - Status label appears in the meta text + const indicator = document.querySelector('.bg-warning'); + expect(indicator).toBeInTheDocument(); + const metaText = screen.getByText(/Waiting \u00B7/); + expect(metaText).toBeInTheDocument(); + }); + }); + + describe('task selection', () => { + it('should render tasks as clickable links', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-123', 'Clickable task')]; + + // Act + render( + + + + ); + + // Assert + const link = screen.getByText('Clickable task').closest('a'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/execution/task-123'); + }); + + it('should navigate to correct task execution page', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'First task'), + createMockTask('task-2', 'Second task'), + ]; + + // Act + render( + + + + ); + + // Assert + const firstLink = screen.getByText('First task').closest('a'); + const secondLink = screen.getByText('Second task').closest('a'); + expect(firstLink).toHaveAttribute('href', '/execution/task-1'); + expect(secondLink).toHaveAttribute('href', '/execution/task-2'); + }); + }); + + describe('task deletion', () => { + it('should render delete button for each task', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Deletable task')]; + + // Act + render( + + + + ); + + // Assert + const deleteButton = document.querySelector('button'); + expect(deleteButton).toBeInTheDocument(); + }); + + it('should show confirmation dialog when delete is clicked', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Deletable task')]; + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + // Act + render( + + + + ); + + const taskCard = screen.getByText('Deletable task').closest('a'); + const deleteButton = taskCard?.querySelector('button'); + if (deleteButton) { + fireEvent.click(deleteButton); + } + + // Assert + expect(confirmSpy).toHaveBeenCalledWith('Delete this task?'); + }); + + it('should call deleteTask when confirmation is accepted', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Deletable task')]; + vi.spyOn(window, 'confirm').mockReturnValue(true); + + // Act + render( + + + + ); + + const taskCard = screen.getByText('Deletable task').closest('a'); + const deleteButton = taskCard?.querySelector('button'); + if (deleteButton) { + fireEvent.click(deleteButton); + } + + // Assert + expect(mockDeleteTask).toHaveBeenCalledWith('task-1'); + }); + + it('should not call deleteTask when confirmation is cancelled', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Deletable task')]; + vi.spyOn(window, 'confirm').mockReturnValue(false); + + // Act + render( + + + + ); + + const taskCard = screen.getByText('Deletable task').closest('a'); + const deleteButton = taskCard?.querySelector('button'); + if (deleteButton) { + fireEvent.click(deleteButton); + } + + // Assert + expect(mockDeleteTask).not.toHaveBeenCalled(); + }); + + it('should prevent navigation when delete button is clicked', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Deletable task')]; + vi.spyOn(window, 'confirm').mockReturnValue(true); + + // Act + render( + + + + ); + + const taskCard = screen.getByText('Deletable task').closest('a'); + const deleteButton = taskCard?.querySelector('button'); + if (deleteButton) { + fireEvent.click(deleteButton); + } + + // Assert - Delete should be called but no navigation + expect(mockDeleteTask).toHaveBeenCalled(); + }); + }); + + describe('clear history', () => { + it('should render Clear all button when tasks exist and no limit', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/clear all/i)).toBeInTheDocument(); + }); + + it('should not render Clear all button when limit is set', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByText(/clear all/i)).not.toBeInTheDocument(); + }); + + it('should show confirmation dialog when Clear all is clicked', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + // Act + render( + + + + ); + + fireEvent.click(screen.getByText(/clear all/i)); + + // Assert + expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to clear all task history?'); + }); + + it('should call clearHistory when confirmation is accepted', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + vi.spyOn(window, 'confirm').mockReturnValue(true); + + // Act + render( + + + + ); + + fireEvent.click(screen.getByText(/clear all/i)); + + // Assert + expect(mockClearHistory).toHaveBeenCalled(); + }); + + it('should not call clearHistory when confirmation is cancelled', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Test task')]; + vi.spyOn(window, 'confirm').mockReturnValue(false); + + // Act + render( + + + + ); + + fireEvent.click(screen.getByText(/clear all/i)); + + // Assert + expect(mockClearHistory).not.toHaveBeenCalled(); + }); + }); + + describe('limit functionality', () => { + it('should limit displayed tasks when limit prop is provided', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Task 1'), + createMockTask('task-2', 'Task 2'), + createMockTask('task-3', 'Task 3'), + createMockTask('task-4', 'Task 4'), + createMockTask('task-5', 'Task 5'), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + expect(screen.getByText('Task 3')).toBeInTheDocument(); + expect(screen.queryByText('Task 4')).not.toBeInTheDocument(); + expect(screen.queryByText('Task 5')).not.toBeInTheDocument(); + }); + + it('should show View all link when more tasks exist than limit', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Task 1'), + createMockTask('task-2', 'Task 2'), + createMockTask('task-3', 'Task 3'), + createMockTask('task-4', 'Task 4'), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/view all 4 tasks/i)).toBeInTheDocument(); + }); + + it('should link to history page in View all link', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Task 1'), + createMockTask('task-2', 'Task 2'), + createMockTask('task-3', 'Task 3'), + ]; + + // Act + render( + + + + ); + + // Assert + const viewAllLink = screen.getByText(/view all/i).closest('a'); + expect(viewAllLink).toHaveAttribute('href', '/history'); + }); + + it('should not show View all link when tasks fit within limit', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Task 1'), + createMockTask('task-2', 'Task 2'), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByText(/view all/i)).not.toBeInTheDocument(); + }); + + it('should show all tasks when no limit is provided', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Task 1'), + createMockTask('task-2', 'Task 2'), + createMockTask('task-3', 'Task 3'), + createMockTask('task-4', 'Task 4'), + createMockTask('task-5', 'Task 5'), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + expect(screen.getByText('Task 3')).toBeInTheDocument(); + expect(screen.getByText('Task 4')).toBeInTheDocument(); + expect(screen.getByText('Task 5')).toBeInTheDocument(); + }); + }); + + describe('time ago display', () => { + it('should show "just now" for recent tasks', () => { + // Arrange + const now = new Date().toISOString(); + mockStoreState.tasks = [createMockTask('task-1', 'Recent task', 'completed', now)]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/just now/i)).toBeInTheDocument(); + }); + + it('should show minutes ago for tasks within an hour', () => { + // Arrange + const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString(); + mockStoreState.tasks = [createMockTask('task-1', 'Old task', 'completed', thirtyMinutesAgo)]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/30m ago/i)).toBeInTheDocument(); + }); + + it('should show hours ago for tasks within a day', () => { + // Arrange + const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(); + mockStoreState.tasks = [createMockTask('task-1', 'Older task', 'completed', fiveHoursAgo)]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/5h ago/i)).toBeInTheDocument(); + }); + + it('should show days ago for tasks older than a day', () => { + // Arrange + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + mockStoreState.tasks = [createMockTask('task-1', 'Very old task', 'completed', threeDaysAgo)]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText(/3d ago/i)).toBeInTheDocument(); + }); + }); + + describe('styling and layout', () => { + it('should render tasks with card styling', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Styled task')]; + + // Act + render( + + + + ); + + // Assert + const taskCard = screen.getByText('Styled task').closest('a'); + expect(taskCard?.className).toContain('rounded-card'); + }); + + it('should render tasks with hover effect', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'Hover task')]; + + // Act + render( + + + + ); + + // Assert + const taskCard = screen.getByText('Hover task').closest('a'); + expect(taskCard?.className).toContain('hover:shadow-card-hover'); + }); + + it('should truncate long task prompts', () => { + // Arrange + mockStoreState.tasks = [createMockTask('task-1', 'This is a very long task prompt that should be truncated')]; + + // Act + render( + + + + ); + + // Assert + const promptElement = screen.getByText(/this is a very long task prompt/i); + expect(promptElement.className).toContain('truncate'); + }); + + it('should render tasks in a vertical list', () => { + // Arrange + mockStoreState.tasks = [ + createMockTask('task-1', 'Task 1'), + createMockTask('task-2', 'Task 2'), + ]; + + // Act + render( + + + + ); + + // Assert + const container = document.querySelector('.space-y-2'); + expect(container).toBeInTheDocument(); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx new file mode 100644 index 000000000..0abd2051c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx @@ -0,0 +1,526 @@ +/** + * Integration tests for TaskInputBar component + * Tests component rendering and user interactions with mocked window.accomplish API + * @module __tests__/integration/renderer/components/TaskInputBar.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TaskInputBar from '@/components/landing/TaskInputBar'; + +// Mock analytics to prevent tracking calls +vi.mock('@/lib/analytics', () => ({ + analytics: { + trackSubmitTask: vi.fn(), + }, +})); + +// Mock accomplish API +const mockAccomplish = { + logEvent: vi.fn().mockResolvedValue(undefined), + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +describe('TaskInputBar Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('should render with empty state', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const textarea = screen.getByRole('textbox'); + expect(textarea).toBeInTheDocument(); + expect(textarea).toHaveValue(''); + }); + + it('should render with default placeholder', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const textarea = screen.getByPlaceholderText('Assign a task or ask anything'); + expect(textarea).toBeInTheDocument(); + }); + + it('should render with custom placeholder', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const customPlaceholder = 'Enter your task here'; + + // Act + render( + + ); + + // Assert + const textarea = screen.getByPlaceholderText(customPlaceholder); + expect(textarea).toBeInTheDocument(); + }); + + it('should render with provided value', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + const taskValue = 'Review my inbox for urgent messages'; + + // Act + render( + + ); + + // Assert + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveValue(taskValue); + }); + + it('should render submit button', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeInTheDocument(); + }); + }); + + describe('user input handling', () => { + it('should call onChange when user types', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + + ); + + // Act + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'New task input' } }); + + // Assert + expect(onChange).toHaveBeenCalledWith('New task input'); + }); + + it('should call onChange with each input change', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + const { rerender } = render( + + ); + + // Act - First change + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'First' } }); + + // Rerender with updated value + rerender( + + ); + + // Act - Second change + fireEvent.change(textarea, { target: { value: 'First input' } }); + + // Assert + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenNthCalledWith(1, 'First'); + expect(onChange).toHaveBeenNthCalledWith(2, 'First input'); + }); + }); + + describe('submit button behavior', () => { + it('should disable submit button when value is empty', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + + it('should disable submit button when value is only whitespace', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + + it('should enable submit button when value has content', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).not.toBeDisabled(); + }); + + it('should call onSubmit when submit button is clicked', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + + ); + + // Act + const submitButton = screen.getByRole('button', { name: /submit/i }); + fireEvent.click(submitButton); + + // Assert + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('should call onSubmit when Enter is pressed without Shift', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + + ); + + // Act + const textarea = screen.getByRole('textbox'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + + // Assert + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('should not call onSubmit when Shift+Enter is pressed', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + + ); + + // Act + const textarea = screen.getByRole('textbox'); + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true }); + + // Assert + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('should not submit when clicking disabled button', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + + ); + + // Act + const submitButton = screen.getByRole('button', { name: /submit/i }); + fireEvent.click(submitButton); + + // Assert + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('loading state', () => { + it('should disable textarea when loading', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const textarea = screen.getByRole('textbox'); + expect(textarea).toBeDisabled(); + }); + + it('should disable submit button when loading', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + + it('should show loading spinner in submit button when loading', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert - Check for the animate-spin class on the loader icon + const submitButton = screen.getByRole('button', { name: /submit/i }); + const spinner = submitButton.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + }); + + it('should have disabled textarea that prevents user input when loading', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + render( + + ); + + // Assert - textarea is disabled, preventing real user interaction + // Note: In jsdom, keydown events still fire on disabled elements, + // but in a real browser, disabled elements don't receive keyboard input + const textarea = screen.getByRole('textbox'); + expect(textarea).toBeDisabled(); + }); + }); + + describe('disabled state', () => { + it('should disable textarea when disabled prop is true', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const textarea = screen.getByRole('textbox'); + expect(textarea).toBeDisabled(); + }); + + it('should disable submit button when disabled prop is true', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const submitButton = screen.getByRole('button', { name: /submit/i }); + expect(submitButton).toBeDisabled(); + }); + }); + + describe('large variant', () => { + it('should apply large text style when large prop is true', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const textarea = screen.getByRole('textbox'); + expect(textarea.className).toContain('text-[20px]'); + }); + + it('should apply default text size when large prop is false', () => { + // Arrange + const onChange = vi.fn(); + const onSubmit = vi.fn(); + + // Act + render( + + ); + + // Assert + const textarea = screen.getByRole('textbox'); + expect(textarea.className).toContain('text-sm'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx new file mode 100644 index 000000000..a4ca005bc --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx @@ -0,0 +1,1122 @@ +/** + * Integration tests for TaskLauncher and TaskLauncherItem components + * Tests rendering, filtering, keyboard navigation, and task selection + * @module __tests__/integration/renderer/components/TaskLauncher.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { Task, TaskStatus } from '@accomplish/shared'; + +// Mock analytics to prevent tracking calls +vi.mock('@/lib/analytics', () => ({ + analytics: { + trackNewTask: vi.fn(), + }, +})); + +// Create mock functions outside of mock factory +const mockStartTask = vi.fn(); +const mockCloseLauncher = vi.fn(); +const mockHasAnyApiKey = vi.fn(); + +// Helper to create mock tasks +function createMockTask( + id: string, + prompt: string = 'Test task', + status: TaskStatus = 'completed', + createdAt?: string +): Task { + return { + id, + prompt, + status, + messages: [], + createdAt: createdAt || new Date().toISOString(), + }; +} + +// Mock accomplish API +const mockAccomplish = { + hasAnyApiKey: mockHasAnyApiKey, + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +// Create a store state holder for testing +let mockStoreState = { + isLauncherOpen: false, + closeLauncher: mockCloseLauncher, + tasks: [] as Task[], + startTask: mockStartTask, +}; + +// Mock the task store +vi.mock('@/stores/taskStore', () => ({ + useTaskStore: () => mockStoreState, +})); + +// Mock framer-motion to simplify testing animations +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Need to import after mocks are set up +import TaskLauncher from '@/components/TaskLauncher/TaskLauncher'; +import TaskLauncherItem from '@/components/TaskLauncher/TaskLauncherItem'; + +describe('TaskLauncherItem', () => { + const mockOnClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('should render task prompt', () => { + // Arrange + const task = createMockTask('task-1', 'Check my email inbox'); + + // Act + render(); + + // Assert + expect(screen.getByText('Check my email inbox')).toBeInTheDocument(); + }); + + it('should render task with truncated long prompt', () => { + // Arrange + const longPrompt = 'This is a very long task prompt that should be truncated when displayed in the UI to prevent overflow'; + const task = createMockTask('task-1', longPrompt); + + // Act + render(); + + // Assert + const promptElement = screen.getByText(longPrompt); + expect(promptElement.className).toContain('truncate'); + }); + }); + + describe('status icons', () => { + it('should show spinning loader for running tasks', () => { + // Arrange + const task = createMockTask('task-1', 'Running task', 'running'); + + // Act + const { container } = render(); + + // Assert - Check for spinning loader icon + const spinner = container.querySelector('.animate-spin'); + expect(spinner).toBeInTheDocument(); + expect(spinner?.getAttribute('class')).toContain('text-primary'); + }); + + it('should show checkmark for completed tasks', () => { + // Arrange + const task = createMockTask('task-1', 'Completed task', 'completed'); + + // Act + const { container } = render(); + + // Assert - CheckCircle2 icon should have green color + const icon = container.querySelector('.text-green-500'); + expect(icon).toBeInTheDocument(); + }); + + it('should show X icon for failed tasks', () => { + // Arrange + const task = createMockTask('task-1', 'Failed task', 'failed'); + + // Act + const { container } = render(); + + // Assert - XCircle icon should have destructive color + const icon = container.querySelector('.text-destructive'); + expect(icon).toBeInTheDocument(); + }); + + it('should show alert icon for cancelled tasks', () => { + // Arrange + const task = createMockTask('task-1', 'Cancelled task', 'cancelled'); + + // Act + const { container } = render(); + + // Assert - AlertCircle icon should have yellow color + const icon = container.querySelector('.text-yellow-500'); + expect(icon).toBeInTheDocument(); + }); + + it('should show alert icon for interrupted tasks', () => { + // Arrange + const task = createMockTask('task-1', 'Interrupted task', 'interrupted'); + + // Act + const { container } = render(); + + // Assert - AlertCircle icon should have yellow color + const icon = container.querySelector('.text-yellow-500'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe('relative date formatting', () => { + it('should show "Today" for tasks created today', () => { + // Arrange + const today = new Date(); + const task = createMockTask('task-1', 'Today task', 'completed', today.toISOString()); + + // Act + render(); + + // Assert + expect(screen.getByText('Today')).toBeInTheDocument(); + }); + + it('should show "Yesterday" for tasks created yesterday', () => { + // Arrange + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const task = createMockTask('task-1', 'Yesterday task', 'completed', yesterday.toISOString()); + + // Act + render(); + + // Assert + expect(screen.getByText('Yesterday')).toBeInTheDocument(); + }); + + it('should show weekday name for tasks within last 7 days', () => { + // Arrange + const twoDaysAgo = new Date(); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + const task = createMockTask('task-1', 'Recent task', 'completed', twoDaysAgo.toISOString()); + + // Act + render(); + + // Assert - Should show weekday name (e.g., "Monday", "Tuesday") + const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const expectedWeekday = weekdays[twoDaysAgo.getDay()]; + expect(screen.getByText(expectedWeekday)).toBeInTheDocument(); + }); + + it('should show month and day for tasks older than 7 days', () => { + // Arrange + const tenDaysAgo = new Date(); + tenDaysAgo.setDate(tenDaysAgo.getDate() - 10); + const task = createMockTask('task-1', 'Old task', 'completed', tenDaysAgo.toISOString()); + + // Act + render(); + + // Assert - Should show format like "Jan 5" + const expectedDate = tenDaysAgo.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + expect(screen.getByText(expectedDate)).toBeInTheDocument(); + }); + }); + + describe('selection state', () => { + it('should highlight when isSelected is true', () => { + // Arrange + const task = createMockTask('task-1', 'Selected task'); + + // Act + const { container } = render(); + + // Assert + const button = container.querySelector('button'); + expect(button?.className).toContain('bg-primary'); + expect(button?.className).toContain('text-primary-foreground'); + }); + + it('should not highlight when isSelected is false', () => { + // Arrange + const task = createMockTask('task-1', 'Unselected task'); + + // Act + const { container } = render(); + + // Assert + const button = container.querySelector('button'); + expect(button?.className).toContain('text-foreground'); + expect(button?.className).toContain('hover:bg-accent'); + }); + + it('should apply different date text color when selected', () => { + // Arrange + const task = createMockTask('task-1', 'Task'); + + // Act + const { container } = render(); + + // Assert - Date text should use primary-foreground opacity + const dateElement = container.querySelector('.text-primary-foreground\\/70'); + expect(dateElement).toBeInTheDocument(); + }); + + it('should apply muted date text color when not selected', () => { + // Arrange + const task = createMockTask('task-1', 'Task'); + + // Act + const { container } = render(); + + // Assert - Date text should use muted foreground + const dateElement = container.querySelector('.text-muted-foreground'); + expect(dateElement).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + it('should call onClick when clicked', () => { + // Arrange + const task = createMockTask('task-1', 'Clickable task'); + + // Act + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('should be a button element', () => { + // Arrange + const task = createMockTask('task-1', 'Task'); + + // Act + render(); + + // Assert + const button = screen.getByRole('button'); + expect(button.tagName).toBe('BUTTON'); + }); + }); +}); + +describe('TaskLauncher', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + mockStoreState = { + isLauncherOpen: false, + closeLauncher: mockCloseLauncher, + tasks: [], + startTask: mockStartTask, + }; + // Set up default provider settings with a ready provider + mockAccomplish.getProviderSettings.mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }); + }); + + describe('opening and closing', () => { + it('should not render when isLauncherOpen is false', () => { + // Arrange + mockStoreState.isLauncherOpen = false; + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByPlaceholderText('Search tasks...')).not.toBeInTheDocument(); + }); + + it('should render when isLauncherOpen is true', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByPlaceholderText('Search tasks...')).toBeInTheDocument(); + }); + + it('should show search input when open', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert + const searchInput = screen.getByPlaceholderText('Search tasks...'); + expect(searchInput).toBeInTheDocument(); + expect(searchInput.tagName).toBe('INPUT'); + }); + + it('should show close button when open', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + }); + + it('should call closeLauncher when Escape is pressed', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'Escape' }); + + // Assert - May be called more than once due to Dialog component + expect(mockCloseLauncher).toHaveBeenCalled(); + }); + + it('should call closeLauncher when close button is clicked', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + // Assert + expect(mockCloseLauncher).toHaveBeenCalledTimes(1); + }); + }); + + describe('new task option', () => { + it('should show "New task" option', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('New task')).toBeInTheDocument(); + }); + + it('should show search query in new task option when search has text', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'my new task' } }); + + // Assert + expect(screen.getByText(/"my new task"/)).toBeInTheDocument(); + }); + + it('should not show search query preview when search is empty', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByText(/—/)).not.toBeInTheDocument(); + }); + + it('should show Plus icon in new task option', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + const { container } = render( + + + + ); + + // Assert - Plus icon should be present + const newTaskButton = screen.getByText('New task').closest('button'); + const icon = newTaskButton?.querySelector('svg'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe('task filtering', () => { + it('should show "Last 7 days" section when no search query', () => { + // Arrange + const today = new Date(); + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Recent task', 'completed', today.toISOString()), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Last 7 days')).toBeInTheDocument(); + }); + + it('should show "Results" section when searching', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Check email'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'email' } }); + + // Assert + expect(screen.getByText('Results')).toBeInTheDocument(); + }); + + it('should filter tasks by search query', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Check my email inbox'), + createMockTask('task-2', 'Review calendar'), + createMockTask('task-3', 'Send email to team'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'email' } }); + + // Assert + expect(screen.getByText('Check my email inbox')).toBeInTheDocument(); + expect(screen.getByText('Send email to team')).toBeInTheDocument(); + expect(screen.queryByText('Review calendar')).not.toBeInTheDocument(); + }); + + it('should be case-insensitive when filtering', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Check my EMAIL inbox'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'email' } }); + + // Assert + expect(screen.getByText('Check my EMAIL inbox')).toBeInTheDocument(); + }); + + it('should show "No tasks found" when search has no results', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Check email'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'nonexistent' } }); + + // Assert + expect(screen.getByText('No tasks found')).toBeInTheDocument(); + }); + + it('should only show tasks from last 7 days when no search', () => { + // Arrange + const today = new Date(); + const fiveDaysAgo = new Date(); + fiveDaysAgo.setDate(today.getDate() - 5); + const tenDaysAgo = new Date(); + tenDaysAgo.setDate(today.getDate() - 10); + + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Recent task', 'completed', fiveDaysAgo.toISOString()), + createMockTask('task-2', 'Old task', 'completed', tenDaysAgo.toISOString()), + ]; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Recent task')).toBeInTheDocument(); + expect(screen.queryByText('Old task')).not.toBeInTheDocument(); + }); + + it('should show all matching tasks regardless of age when searching', () => { + // Arrange + const tenDaysAgo = new Date(); + tenDaysAgo.setDate(tenDaysAgo.getDate() - 10); + + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Old email task', 'completed', tenDaysAgo.toISOString()), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'email' } }); + + // Assert + expect(screen.getByText('Old email task')).toBeInTheDocument(); + }); + + it('should limit results to 10 tasks', () => { + // Arrange + const today = new Date(); + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = Array.from({ length: 15 }, (_, i) => + createMockTask(`task-${i}`, `Task ${i}`, 'completed', today.toISOString()) + ); + + // Act + render( + + + + ); + + // Assert - Should show 10 tasks maximum + // Check for task prompts (Task 0 through Task 9) + expect(screen.getByText('Task 0')).toBeInTheDocument(); + expect(screen.getByText('Task 9')).toBeInTheDocument(); + expect(screen.queryByText('Task 10')).not.toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + it('should start with first item selected', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + const { container } = render( + + + + ); + + // Assert - "New task" should be selected (has bg-primary) + const newTaskButton = screen.getByText('New task').closest('button'); + expect(newTaskButton?.className).toContain('bg-primary'); + }); + + it('should move selection down with ArrowDown', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'First task'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); + + // Assert - First task should now be selected + const taskButton = screen.getByText('First task').closest('button'); + expect(taskButton?.className).toContain('bg-primary'); + }); + + it('should move selection up with ArrowUp', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'First task'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); // Move to first task + fireEvent.keyDown(searchInput, { key: 'ArrowUp' }); // Move back to New task + + // Assert - "New task" should be selected again + const newTaskButton = screen.getByText('New task').closest('button'); + expect(newTaskButton?.className).toContain('bg-primary'); + }); + + it('should not move selection above first item', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'ArrowUp' }); // Try to move up from first item + + // Assert - "New task" should still be selected + const newTaskButton = screen.getByText('New task').closest('button'); + expect(newTaskButton?.className).toContain('bg-primary'); + }); + + it('should not move selection below last item', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Only task'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); // Move to task + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); // Try to move past last item + + // Assert - Last task should still be selected + const taskButton = screen.getByText('Only task').closest('button'); + expect(taskButton?.className).toContain('bg-primary'); + }); + + it('should reset selection when reopened', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-1', 'Task'), + ]; + + // Act + const { rerender } = render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); // Move to task + + // Close and reopen + mockStoreState.isLauncherOpen = false; + rerender( + + + + ); + + mockStoreState.isLauncherOpen = true; + rerender( + + + + ); + + // Assert - Selection should be back at first item + const newTaskButton = screen.getByText('New task').closest('button'); + expect(newTaskButton?.className).toContain('bg-primary'); + }); + }); + + describe('task selection', () => { + it('should navigate to home when New task is selected with empty search', async () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + const newTaskButton = screen.getByText('New task').closest('button'); + if (newTaskButton) { + fireEvent.click(newTaskButton); + } + + // Assert + await waitFor(() => { + expect(mockCloseLauncher).toHaveBeenCalled(); + }); + }); + + it('should start new task when New task is selected with search text', async () => { + // Arrange + mockStoreState.isLauncherOpen = true; + const mockTask = createMockTask('new-task', 'Test prompt'); + mockStartTask.mockResolvedValue(mockTask); + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'Test prompt' } }); + + const newTaskButton = screen.getByText('New task').closest('button'); + if (newTaskButton) { + fireEvent.click(newTaskButton); + } + + // Assert + await waitFor(() => { + expect(mockAccomplish.getProviderSettings).toHaveBeenCalled(); + expect(mockCloseLauncher).toHaveBeenCalled(); + expect(mockStartTask).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Test prompt', + }) + ); + }); + }); + + it('should navigate to home if no provider is ready when starting new task', async () => { + // Arrange - No ready provider + mockStoreState.isLauncherOpen = true; + mockAccomplish.getProviderSettings.mockResolvedValue({ + activeProviderId: null, + connectedProviders: {}, + debugMode: false, + }); + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'Test prompt' } }); + + const newTaskButton = screen.getByText('New task').closest('button'); + if (newTaskButton) { + fireEvent.click(newTaskButton); + } + + // Assert + await waitFor(() => { + expect(mockAccomplish.getProviderSettings).toHaveBeenCalled(); + expect(mockCloseLauncher).toHaveBeenCalled(); + expect(mockStartTask).not.toHaveBeenCalled(); + }); + }); + + it('should navigate to task when task item is clicked', async () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-123', 'Existing task'), + ]; + + // Act + render( + + + + ); + + const taskButton = screen.getByText('Existing task').closest('button'); + if (taskButton) { + fireEvent.click(taskButton); + } + + // Assert + await waitFor(() => { + expect(mockCloseLauncher).toHaveBeenCalled(); + }); + }); + + it('should navigate to task when Enter is pressed on selected task', async () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = [ + createMockTask('task-123', 'Keyboard task'), + ]; + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); // Move to task + fireEvent.keyDown(searchInput, { key: 'Enter' }); // Select task + + // Assert + await waitFor(() => { + expect(mockCloseLauncher).toHaveBeenCalled(); + }); + }); + }); + + describe('UI elements', () => { + it('should show Search icon', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert - Search icon should be present + // Check that the search input exists (which has the Search icon next to it) + const searchInput = screen.getByPlaceholderText('Search tasks...'); + expect(searchInput).toBeInTheDocument(); + }); + + it('should show keyboard hints in footer', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert + expect(screen.getByText('Navigate')).toBeInTheDocument(); + expect(screen.getByText('Select')).toBeInTheDocument(); + expect(screen.getByText('Close')).toBeInTheDocument(); + }); + + it('should render overlay when open', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + render( + + + + ); + + // Assert - When open, the dialog content should be visible + expect(screen.getByPlaceholderText('Search tasks...')).toBeInTheDocument(); + expect(screen.getByText('New task')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle empty tasks array', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + mockStoreState.tasks = []; + + // Act + render( + + + + ); + + // Assert - Should show New task and no error + expect(screen.getByText('New task')).toBeInTheDocument(); + expect(screen.queryByText('Last 7 days')).not.toBeInTheDocument(); + }); + + it('should trim whitespace from search query', async () => { + // Arrange + mockStoreState.isLauncherOpen = true; + const mockTask = createMockTask('new-task', 'Trimmed prompt'); + mockStartTask.mockResolvedValue(mockTask); + + // Act + render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: ' Trimmed prompt ' } }); + + const newTaskButton = screen.getByText('New task').closest('button'); + if (newTaskButton) { + fireEvent.click(newTaskButton); + } + + // Assert + await waitFor(() => { + expect(mockStartTask).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: 'Trimmed prompt', + }) + ); + }); + }); + + it('should clear search when reopened', () => { + // Arrange + mockStoreState.isLauncherOpen = true; + + // Act + const { rerender } = render( + + + + ); + + const searchInput = screen.getByPlaceholderText('Search tasks...'); + fireEvent.change(searchInput, { target: { value: 'some search' } }); + + // Close and reopen + mockStoreState.isLauncherOpen = false; + rerender( + + + + ); + + mockStoreState.isLauncherOpen = true; + rerender( + + + + ); + + // Assert - Search should be cleared + const newSearchInput = screen.getByPlaceholderText('Search tasks...'); + expect(newSearchInput).toHaveValue(''); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx new file mode 100644 index 000000000..7b213d2a7 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx @@ -0,0 +1,1380 @@ +/** + * Integration tests for Execution page + * Tests rendering with active task, message display, and permission dialog + * @module __tests__/integration/renderer/pages/Execution.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import type { Task, TaskStatus, TaskMessage, PermissionRequest } from '@accomplish/shared'; + +// Create mock functions +const mockLoadTaskById = vi.fn(); +const mockAddTaskUpdate = vi.fn(); +const mockAddTaskUpdateBatch = vi.fn(); +const mockUpdateTaskStatus = vi.fn(); +const mockSetPermissionRequest = vi.fn(); +const mockRespondToPermission = vi.fn(); +const mockSendFollowUp = vi.fn(); +const mockCancelTask = vi.fn(); +const mockInterruptTask = vi.fn(); +const mockOnTaskUpdate = vi.fn(); +const mockOnTaskUpdateBatch = vi.fn(); +const mockOnPermissionRequest = vi.fn(); +const mockOnTaskStatusChange = vi.fn(); + +// Helper to create mock task +function createMockTask( + id: string, + prompt: string = 'Test task', + status: TaskStatus = 'running', + messages: TaskMessage[] = [] +): Task { + return { + id, + prompt, + status, + messages, + createdAt: new Date().toISOString(), + }; +} + +// Helper to create mock message +function createMockMessage( + id: string, + type: 'assistant' | 'user' | 'tool' | 'system' = 'assistant', + content: string = 'Test message' +): TaskMessage { + return { + id, + type, + content, + timestamp: new Date().toISOString(), + }; +} + +// Mock accomplish API +const mockAccomplish = { + onTaskUpdate: mockOnTaskUpdate.mockReturnValue(() => {}), + onTaskUpdateBatch: mockOnTaskUpdateBatch.mockReturnValue(() => {}), + onPermissionRequest: mockOnPermissionRequest.mockReturnValue(() => {}), + onTaskStatusChange: mockOnTaskStatusChange.mockReturnValue(() => {}), + onDebugLog: vi.fn().mockReturnValue(() => {}), + onDebugModeChange: vi.fn().mockReturnValue(() => {}), + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + getDebugMode: vi.fn().mockResolvedValue(false), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +// Mock store state holder +let mockStoreState: { + currentTask: Task | null; + loadTaskById: typeof mockLoadTaskById; + isLoading: boolean; + error: string | null; + addTaskUpdate: typeof mockAddTaskUpdate; + addTaskUpdateBatch: typeof mockAddTaskUpdateBatch; + updateTaskStatus: typeof mockUpdateTaskStatus; + setPermissionRequest: typeof mockSetPermissionRequest; + permissionRequest: PermissionRequest | null; + respondToPermission: typeof mockRespondToPermission; + sendFollowUp: typeof mockSendFollowUp; + cancelTask: typeof mockCancelTask; + interruptTask: typeof mockInterruptTask; + setupProgress: string | null; + setupProgressTaskId: string | null; + setupDownloadStep: number; +} = { + currentTask: null, + loadTaskById: mockLoadTaskById, + isLoading: false, + error: null, + addTaskUpdate: mockAddTaskUpdate, + addTaskUpdateBatch: mockAddTaskUpdateBatch, + updateTaskStatus: mockUpdateTaskStatus, + setPermissionRequest: mockSetPermissionRequest, + permissionRequest: null, + respondToPermission: mockRespondToPermission, + sendFollowUp: mockSendFollowUp, + cancelTask: mockCancelTask, + interruptTask: mockInterruptTask, + setupProgress: null, + setupProgressTaskId: null, + setupDownloadStep: 1, +}; + +// Mock the task store +vi.mock('@/stores/taskStore', () => ({ + useTaskStore: () => mockStoreState, +})); + +// Mock framer-motion for simpler testing +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + button: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( + + ), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Mock StreamingText component +vi.mock('@/components/ui/streaming-text', () => ({ + StreamingText: ({ text, children }: { text: string; children: (text: string) => React.ReactNode }) => ( + <>{children(text)} + ), +})); + +// Mock openwork icon +vi.mock('/assets/openwork-icon.png', () => ({ default: 'openwork-icon.png' })); + +// Import after mocks +import ExecutionPage from '@/pages/Execution'; + +// Wrapper component for routing tests +function renderWithRouter(taskId: string = 'task-123') { + return render( + + + } /> + Home Page} /> + + + ); +} + +describe('Execution Page Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + mockStoreState = { + currentTask: null, + loadTaskById: mockLoadTaskById, + isLoading: false, + error: null, + addTaskUpdate: mockAddTaskUpdate, + addTaskUpdateBatch: mockAddTaskUpdateBatch, + updateTaskStatus: mockUpdateTaskStatus, + setPermissionRequest: mockSetPermissionRequest, + permissionRequest: null, + respondToPermission: mockRespondToPermission, + sendFollowUp: mockSendFollowUp, + cancelTask: mockCancelTask, + interruptTask: mockInterruptTask, + setupProgress: null, + setupProgressTaskId: null, + setupDownloadStep: 1, + }; + }); + + describe('rendering with active task', () => { + it('should call loadTaskById on mount', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(mockLoadTaskById).toHaveBeenCalledWith('task-123'); + }); + + it('should display loading spinner when no task loaded yet', () => { + // Arrange - no current task + + // Act + renderWithRouter('task-123'); + + // Assert + const spinner = document.querySelector('.animate-spin-ccw'); + expect(spinner).toBeInTheDocument(); + }); + + it('should display task prompt in header', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Review my email inbox'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Review my email inbox')).toBeInTheDocument(); + }); + + it('should display running status badge for running task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Running task', 'running'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + it('should display completed status badge for completed task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Done task', 'completed'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('should display failed status badge for failed task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Failed task', 'failed'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Failed')).toBeInTheDocument(); + }); + + it('should display cancelled status badge for cancelled task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Cancelled task', 'cancelled'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Cancelled')).toBeInTheDocument(); + }); + + it('should display queued status badge for queued task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Queued task', 'queued'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Queued')).toBeInTheDocument(); + }); + + it('should display stopped status badge for interrupted task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Stopped task', 'interrupted'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Stopped')).toBeInTheDocument(); + }); + + it('should render back button', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123'); + + // Act + renderWithRouter('task-123'); + + // Assert - Look for the back arrow button + const buttons = screen.getAllByRole('button'); + const backButton = buttons.find(btn => btn.querySelector('svg')); + expect(backButton).toBeInTheDocument(); + }); + + it('should not render cancel button (removed from UI)', () => { + // Arrange - Cancel button was removed, only Stop button remains + mockStoreState.currentTask = createMockTask('task-123', 'Running', 'running'); + + // Act + renderWithRouter('task-123'); + + // Assert - Cancel button should not exist + expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument(); + }); + }); + + describe('message display', () => { + it('should display user messages', () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'user', 'Check my inbox'), + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Check my inbox')).toBeInTheDocument(); + }); + + it('should display assistant messages', () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'assistant', 'I will check your inbox now.'), + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('I will check your inbox now.')).toBeInTheDocument(); + }); + + it('should display tool messages with tool name', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Reading files', + toolName: 'Read', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Reading files')).toBeInTheDocument(); + }); + + it('should display multiple messages in order', () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'user', 'First message'), + createMockMessage('msg-2', 'assistant', 'Second message'), + createMockMessage('msg-3', 'user', 'Third message'), + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('First message')).toBeInTheDocument(); + expect(screen.getByText('Second message')).toBeInTheDocument(); + expect(screen.getByText('Third message')).toBeInTheDocument(); + }); + + it('should show "Thinking..." indicator when running without tool', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', []); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Thinking...')).toBeInTheDocument(); + }); + + it('should display message timestamps', () => { + // Arrange + const timestamp = new Date().toISOString(); + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'assistant', + content: 'Test message', + timestamp, + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'completed', messages); + + // Act + renderWithRouter('task-123'); + + // Assert - Check that a time is displayed + const timeRegex = /\d{1,2}:\d{2}:\d{2}/; + const timeElements = screen.getAllByText(timeRegex); + expect(timeElements.length).toBeGreaterThan(0); + }); + }); + + describe('permission dialog', () => { + it('should display permission dialog when permission request exists', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'tool', + toolName: 'Bash', + toolInput: { command: 'rm -rf /' }, + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Permission Required')).toBeInTheDocument(); + }); + + it('should display tool name in permission dialog', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'tool', + toolName: 'Bash', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText(/tool:\s*bash/i)).toBeInTheDocument(); + }); + + it('should render Allow and Deny buttons in permission dialog', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'tool', + toolName: 'Write', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByRole('button', { name: /allow/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /deny/i })).toBeInTheDocument(); + }); + + it('should call respondToPermission with allow when Allow is clicked', async () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'tool', + toolName: 'Write', + createdAt: new Date().toISOString(), + }; + + renderWithRouter('task-123'); + + // Act + const allowButton = screen.getByRole('button', { name: /allow/i }); + fireEvent.click(allowButton); + + // Assert + await waitFor(() => { + expect(mockRespondToPermission).toHaveBeenCalledWith({ + requestId: 'perm-1', + taskId: 'task-123', + decision: 'allow', + }); + }); + }); + + it('should call respondToPermission with deny when Deny is clicked', async () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'tool', + toolName: 'Write', + createdAt: new Date().toISOString(), + }; + + renderWithRouter('task-123'); + + // Act + const denyButton = screen.getByRole('button', { name: /deny/i }); + fireEvent.click(denyButton); + + // Assert + await waitFor(() => { + expect(mockRespondToPermission).toHaveBeenCalledWith({ + requestId: 'perm-1', + taskId: 'task-123', + decision: 'deny', + }); + }); + }); + + it('should display file permission specific UI for file type', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'create', + filePath: '/path/to/file.txt', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('File Permission Required')).toBeInTheDocument(); + expect(screen.getByText('CREATE')).toBeInTheDocument(); + expect(screen.getByText('/path/to/file.txt')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('should display error message when error exists', () => { + // Arrange + mockStoreState.error = 'Task not found'; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Task not found')).toBeInTheDocument(); + }); + + it('should display Go Home button on error', () => { + // Arrange + mockStoreState.error = 'Something went wrong'; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByRole('button', { name: /go home/i })).toBeInTheDocument(); + }); + }); + + describe('task controls', () => { + it('should call interruptTask when Stop button is clicked', async () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Running', 'running'); + + renderWithRouter('task-123'); + + // Act - Find the stop button (square icon) + const stopButton = screen.getByTitle(/stop agent/i); + fireEvent.click(stopButton); + + // Assert + await waitFor(() => { + expect(mockInterruptTask).toHaveBeenCalled(); + }); + }); + }); + + describe('follow-up input', () => { + it('should show follow-up input for completed task with session', () => { + // Arrange + const task = createMockTask('task-123', 'Done', 'completed'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByPlaceholderText(/give new instructions/i)).toBeInTheDocument(); + }); + + it('should show follow-up input for interrupted task with session', () => { + // Arrange + const task = createMockTask('task-123', 'Stopped', 'interrupted'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByPlaceholderText(/give new instructions/i)).toBeInTheDocument(); + }); + + it('should show "Start New Task" button for completed task without session', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Done', 'completed'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByRole('button', { name: /start new task/i })).toBeInTheDocument(); + }); + + it('should call sendFollowUp when follow-up is submitted', async () => { + // Arrange + const task = createMockTask('task-123', 'Done', 'completed'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + renderWithRouter('task-123'); + + // Act + const input = screen.getByPlaceholderText(/give new instructions/i); + fireEvent.change(input, { target: { value: 'Continue with the next step' } }); + + const sendButton = screen.getByRole('button', { name: /send/i }); + fireEvent.click(sendButton); + + // Assert + await waitFor(() => { + expect(mockSendFollowUp).toHaveBeenCalledWith('Continue with the next step'); + }); + }); + + it('should call sendFollowUp when Enter is pressed', async () => { + // Arrange + const task = createMockTask('task-123', 'Done', 'completed'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + renderWithRouter('task-123'); + + // Act + const input = screen.getByPlaceholderText(/give new instructions/i); + fireEvent.change(input, { target: { value: 'Do more work' } }); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + + // Assert + await waitFor(() => { + expect(mockSendFollowUp).toHaveBeenCalledWith('Do more work'); + }); + }); + + it('should disable follow-up input when loading', () => { + // Arrange + const task = createMockTask('task-123', 'Done', 'completed'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + mockStoreState.isLoading = true; + + // Act + renderWithRouter('task-123'); + + // Assert + const input = screen.getByPlaceholderText(/give new instructions/i); + expect(input).toBeDisabled(); + }); + + it('should disable send button when follow-up is empty', () => { + // Arrange + const task = createMockTask('task-123', 'Done', 'completed'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert + const sendButton = screen.getByRole('button', { name: /send/i }); + expect(sendButton).toBeDisabled(); + }); + }); + + describe('queued state', () => { + it('should show waiting message for queued task without messages', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Queued task', 'queued'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText(/waiting for another task/i)).toBeInTheDocument(); + }); + + it('should show inline waiting indicator for queued task with messages', () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'user', 'Previous message'), + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Queued', 'queued', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Previous message')).toBeInTheDocument(); + expect(screen.getByText(/waiting for another task/i)).toBeInTheDocument(); + }); + }); + + describe('event subscriptions', () => { + it('should subscribe to task updates on mount', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(mockOnTaskUpdate).toHaveBeenCalled(); + }); + + it('should subscribe to task update batches on mount', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(mockOnTaskUpdateBatch).toHaveBeenCalled(); + }); + + it('should subscribe to permission requests on mount', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(mockOnPermissionRequest).toHaveBeenCalled(); + }); + + it('should subscribe to task status changes on mount', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(mockOnTaskStatusChange).toHaveBeenCalled(); + }); + }); + + describe('browser installation modal', () => { + it('should show download modal when setupProgress contains "download"', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = 'Downloading Chromium 50%'; + mockStoreState.setupProgressTaskId = 'task-123'; + mockStoreState.setupDownloadStep = 1; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Chrome not installed')).toBeInTheDocument(); + expect(screen.getByText('Installing browser for automation...')).toBeInTheDocument(); + expect(screen.getByText('Downloading...')).toBeInTheDocument(); + }); + + it('should show download modal when setupProgress contains "% of"', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = '50% of 160 MB'; + mockStoreState.setupProgressTaskId = 'task-123'; + mockStoreState.setupDownloadStep = 1; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Chrome not installed')).toBeInTheDocument(); + }); + + it('should calculate overall progress for step 1 (Chromium)', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = 'Downloading 50%'; + mockStoreState.setupProgressTaskId = 'task-123'; + mockStoreState.setupDownloadStep = 1; + + // Act + renderWithRouter('task-123'); + + // Assert - 50% * 0.64 = 32% + expect(screen.getByText('32%')).toBeInTheDocument(); + }); + + it('should calculate overall progress for step 2 (FFMPEG)', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = 'Downloading 50%'; + mockStoreState.setupProgressTaskId = 'task-123'; + mockStoreState.setupDownloadStep = 2; + + // Act + renderWithRouter('task-123'); + + // Assert - 64 + Math.round(50 * 0.01) = 64 + 1 = 65% + expect(screen.getByText('65%')).toBeInTheDocument(); + }); + + it('should calculate overall progress for step 3 (Headless)', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = 'Downloading 50%'; + mockStoreState.setupProgressTaskId = 'task-123'; + mockStoreState.setupDownloadStep = 3; + + // Act + renderWithRouter('task-123'); + + // Assert - 65 + Math.round(50 * 0.35) = 65 + 18 = 83% + expect(screen.getByText('83%')).toBeInTheDocument(); + }); + + it('should not show download modal for different task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = 'Downloading 50%'; + mockStoreState.setupProgressTaskId = 'different-task'; + mockStoreState.setupDownloadStep = 1; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.queryByText('Chrome not installed')).not.toBeInTheDocument(); + }); + + it('should not show download modal when setupProgress is null', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = null; + mockStoreState.setupProgressTaskId = 'task-123'; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.queryByText('Chrome not installed')).not.toBeInTheDocument(); + }); + + it('should show one-time setup message', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.setupProgress = 'Downloading 50%'; + mockStoreState.setupProgressTaskId = 'task-123'; + mockStoreState.setupDownloadStep = 1; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText(/one-time setup/i)).toBeInTheDocument(); + expect(screen.getByText(/~250 MB total/i)).toBeInTheDocument(); + }); + }); + + describe('file permission dialog details', () => { + it('should show target path for rename/move operations', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'rename', + filePath: '/path/to/old.txt', + targetPath: '/path/to/new.txt', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('/path/to/old.txt')).toBeInTheDocument(); + expect(screen.getByText(/new\.txt/)).toBeInTheDocument(); + }); + + it('should show content preview for file operations', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'create', + filePath: '/path/to/file.txt', + contentPreview: 'This is the file content preview...', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Preview content')).toBeInTheDocument(); + }); + + it('should show delete operation warning UI', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'delete', + filePath: '/path/to/file.txt', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert - delete operations show warning UI with title and button, not a badge + expect(screen.getByText('File Deletion Warning')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('should show overwrite operation badge', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'overwrite', + filePath: '/path/to/file.txt', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('OVERWRITE')).toBeInTheDocument(); + }); + + it('should show modify operation badge', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'modify', + filePath: '/path/to/file.txt', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('MODIFY')).toBeInTheDocument(); + }); + + it('should show move operation badge', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'file', + fileOperation: 'move', + filePath: '/path/to/file.txt', + targetPath: '/new/path/file.txt', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('MOVE')).toBeInTheDocument(); + }); + + it('should show tool name in tool permission dialog', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running'); + mockStoreState.permissionRequest = { + id: 'perm-1', + taskId: 'task-123', + type: 'tool', + toolName: 'Bash', + createdAt: new Date().toISOString(), + }; + + // Act + renderWithRouter('task-123'); + + // Assert - Tool permission UI shows "Allow {toolName}?" + expect(screen.getByText('Allow Bash?')).toBeInTheDocument(); + }); + }); + + describe('task complete states', () => { + it('should navigate home when clicking Start New Task for failed task without session', async () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Failed', 'failed'); + + // Act + renderWithRouter('task-123'); + + // Assert + const startNewButton = screen.getByRole('button', { name: /start new task/i }); + expect(startNewButton).toBeInTheDocument(); + + // Click the button - it should navigate to home + fireEvent.click(startNewButton); + + // Verify navigation happened by checking for Home Page text + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + }); + }); + + it('should show follow-up input for interrupted task', () => { + // Arrange - interrupted task without session still shows follow-up + mockStoreState.currentTask = createMockTask('task-123', 'Stopped', 'interrupted'); + + // Act + renderWithRouter('task-123'); + + // Assert - canFollowUp is true for interrupted status + // Look for the retry placeholder text + expect(screen.getByPlaceholderText(/send a new instruction to retry/i)).toBeInTheDocument(); + }); + + it('should show task cancelled message for cancelled task', () => { + // Arrange + mockStoreState.currentTask = createMockTask('task-123', 'Cancelled', 'cancelled'); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText(/task cancelled/i)).toBeInTheDocument(); + }); + + it('should show Continue button for interrupted task with session and messages', () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'assistant', 'I was working on something'), + ]; + const task = createMockTask('task-123', 'Stopped', 'interrupted', messages); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument(); + }); + + it('should show Done Continue button for completed task with session when waiting for user', () => { + // Arrange - message must contain a "waiting for user" pattern to show Done, Continue button + const messages = [ + createMockMessage('msg-1', 'assistant', 'Please log in to your account. Let me know when you are done.'), + ]; + const task = createMockTask('task-123', 'Done', 'completed', messages); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert - button shows because isWaitingForUser() returns true for this message + expect(screen.getByRole('button', { name: /done, continue/i })).toBeInTheDocument(); + }); + + it('should call sendFollowUp with continue when Continue button is clicked', async () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'assistant', 'I was working on something'), + ]; + const task = createMockTask('task-123', 'Stopped', 'interrupted', messages); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + renderWithRouter('task-123'); + + // Act + const continueButton = screen.getByRole('button', { name: /continue/i }); + fireEvent.click(continueButton); + + // Assert + await waitFor(() => { + expect(mockSendFollowUp).toHaveBeenCalledWith('continue'); + }); + }); + }); + + describe('system messages', () => { + it('should display system messages with System label', () => { + // Arrange + const messages = [ + createMockMessage('msg-1', 'system', 'System initialization complete'), + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getByText('System initialization complete')).toBeInTheDocument(); + }); + }); + + describe('default status badge', () => { + it('should display raw status for unknown status', () => { + // Arrange + const task = createMockTask('task-123', 'Task', 'unknown' as TaskStatus); + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('unknown')).toBeInTheDocument(); + }); + }); + + describe('tool message icons', () => { + it('should display Glob tool with search icon label', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Finding files', + toolName: 'Glob', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Finding files')).toBeInTheDocument(); + }); + + it('should display Grep tool with search label', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Searching code', + toolName: 'Grep', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Searching code')).toBeInTheDocument(); + }); + + it('should display Write tool', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Writing file', + toolName: 'Write', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Writing file')).toBeInTheDocument(); + }); + + it('should display Edit tool', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Editing file', + toolName: 'Edit', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Editing file')).toBeInTheDocument(); + }); + + it('should display Task agent tool', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Running agent', + toolName: 'Task', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Running agent')).toBeInTheDocument(); + }); + + it('should display dev_browser_execute tool', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Executing browser action', + toolName: 'dev_browser_execute', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('Executing browser action')).toBeInTheDocument(); + }); + + it('should display unknown tool with fallback icon', () => { + // Arrange + const messages: TaskMessage[] = [ + { + id: 'msg-1', + type: 'tool', + content: 'Unknown operation', + toolName: 'CustomTool', + timestamp: new Date().toISOString(), + }, + ]; + mockStoreState.currentTask = createMockTask('task-123', 'Task', 'running', messages); + + // Act + renderWithRouter('task-123'); + + // Assert + expect(screen.getByText('CustomTool')).toBeInTheDocument(); + }); + }); + + + describe('follow-up placeholder text variations', () => { + it('should show follow-up input for interrupted task even without session', () => { + // Arrange + const task = createMockTask('task-123', 'Stopped', 'interrupted'); + // No sessionId - but canFollowUp is true for interrupted status + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert - for interrupted, follow-up input is shown even without session + // The placeholder says "Send a new instruction to retry..." + const input = screen.getByPlaceholderText(/send a new instruction to retry/i); + expect(input).toBeInTheDocument(); + }); + + it('should show retry placeholder for interrupted task with session', () => { + // Arrange + const task = createMockTask('task-123', 'Stopped', 'interrupted'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + // Act + renderWithRouter('task-123'); + + // Assert + const input = screen.getByPlaceholderText(/give new instructions/i); + expect(input).toBeInTheDocument(); + }); + }); + + describe('error navigation', () => { + it('should navigate home when Go Home button is clicked', async () => { + // Arrange + mockStoreState.error = 'Task not found'; + + // Act + renderWithRouter('task-123'); + + const goHomeButton = screen.getByRole('button', { name: /go home/i }); + fireEvent.click(goHomeButton); + + // Assert + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + }); + }); + }); + + describe('follow-up input empty check', () => { + it('should not call sendFollowUp when follow-up is only whitespace', async () => { + // Arrange + const task = createMockTask('task-123', 'Done', 'completed'); + task.sessionId = 'session-abc'; + mockStoreState.currentTask = task; + + renderWithRouter('task-123'); + + // Act + const input = screen.getByPlaceholderText(/give new instructions/i); + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + + // Assert + await waitFor(() => { + expect(mockSendFollowUp).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx new file mode 100644 index 000000000..2282232e8 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx @@ -0,0 +1,594 @@ +/** + * Integration tests for Home page + * Tests initial render, task input integration, and loading state + * @module __tests__/integration/renderer/pages/Home.integration.test + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { Task, TaskStatus } from '@accomplish/shared'; + +// Mock analytics to prevent tracking calls +vi.mock('@/lib/analytics', () => ({ + analytics: { + trackSubmitTask: vi.fn(), + }, +})); + +// Create mock functions +const mockStartTask = vi.fn(); +const mockAddTaskUpdate = vi.fn(); +const mockSetPermissionRequest = vi.fn(); +const mockHasAnyApiKey = vi.fn(); +const mockOnTaskUpdate = vi.fn(); +const mockOnPermissionRequest = vi.fn(); +const mockLogEvent = vi.fn(); + +// Helper to create a mock task +function createMockTask( + id: string, + prompt: string = 'Test task', + status: TaskStatus = 'running' +): Task { + return { + id, + prompt, + status, + messages: [], + createdAt: new Date().toISOString(), + }; +} + +// Mock accomplish API +const mockAccomplish = { + hasAnyApiKey: mockHasAnyApiKey, + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + onTaskUpdate: mockOnTaskUpdate.mockReturnValue(() => {}), + onPermissionRequest: mockOnPermissionRequest.mockReturnValue(() => {}), + logEvent: mockLogEvent.mockResolvedValue(undefined), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +// Mock store state holder +let mockStoreState = { + startTask: mockStartTask, + isLoading: false, + addTaskUpdate: mockAddTaskUpdate, + setPermissionRequest: mockSetPermissionRequest, +}; + +// Mock the task store +vi.mock('@/stores/taskStore', () => ({ + useTaskStore: () => mockStoreState, +})); + +// Mock framer-motion for simpler testing +vi.mock('framer-motion', () => ({ + motion: { + h1: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +

{children}

+ ), + div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + button: ({ children, onClick, ...props }: { children: React.ReactNode; onClick?: () => void; [key: string]: unknown }) => ( + + ), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Mock SettingsDialog +vi.mock('@/components/layout/SettingsDialog', () => ({ + default: ({ open, onOpenChange, onApiKeySaved }: { + open: boolean; + onOpenChange: (open: boolean) => void; + onApiKeySaved?: () => void; + }) => ( + open ? ( +
+ + {onApiKeySaved && ( + + )} +
+ ) : null + ), +})); + +// Import after mocks +import HomePage from '@/pages/Home'; + +// Mock images +vi.mock('/assets/usecases/calendar-prep-notes.png', () => ({ default: 'calendar.png' })); +vi.mock('/assets/usecases/inbox-promo-cleanup.png', () => ({ default: 'inbox.png' })); +vi.mock('/assets/usecases/competitor-pricing-deck.png', () => ({ default: 'competitor.png' })); +vi.mock('/assets/usecases/notion-api-audit.png', () => ({ default: 'notion.png' })); +vi.mock('/assets/usecases/staging-vs-prod-visual.png', () => ({ default: 'staging.png' })); +vi.mock('/assets/usecases/prod-broken-links.png', () => ({ default: 'broken-links.png' })); +vi.mock('/assets/usecases/stock-portfolio-alerts.png', () => ({ default: 'stock.png' })); +vi.mock('/assets/usecases/job-application-automation.png', () => ({ default: 'job.png' })); +vi.mock('/assets/usecases/event-calendar-builder.png', () => ({ default: 'event.png' })); + +describe('Home Page Integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state + mockStoreState = { + startTask: mockStartTask, + isLoading: false, + addTaskUpdate: mockAddTaskUpdate, + setPermissionRequest: mockSetPermissionRequest, + }; + // Default to having API key (legacy) + mockHasAnyApiKey.mockResolvedValue(true); + // Default to having a ready provider (new provider settings) + mockAccomplish.getProviderSettings.mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }); + }); + + describe('initial render', () => { + it('should render the main heading', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(screen.getByRole('heading', { name: /what will you accomplish today/i })).toBeInTheDocument(); + }); + + it('should render the task input bar', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const textarea = screen.getByPlaceholderText(/describe a task and let ai handle the rest/i); + expect(textarea).toBeInTheDocument(); + }); + + it('should render submit button', () => { + // Arrange & Act + render( + + + + ); + + // Assert + const submitButton = screen.getByTitle('Submit'); + expect(submitButton).toBeInTheDocument(); + }); + + it('should render example prompts section', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(screen.getByText(/example prompts/i)).toBeInTheDocument(); + }); + + it('should render use case example cards', async () => { + // Arrange & Act + render( + + + + ); + + // Assert - Check for some example use cases (expanded by default) + await waitFor(() => { + expect(screen.getByText('Calendar Prep Notes')).toBeInTheDocument(); + expect(screen.getByText('Inbox Promo Cleanup')).toBeInTheDocument(); + }); + }); + + it('should subscribe to task events on mount', () => { + // Arrange & Act + render( + + + + ); + + // Assert + expect(mockOnTaskUpdate).toHaveBeenCalled(); + expect(mockOnPermissionRequest).toHaveBeenCalled(); + }); + }); + + describe('task input integration', () => { + it('should update input value when user types', () => { + // Arrange + render( + + + + ); + + // Act + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: 'Check my calendar' } }); + + // Assert + expect(textarea).toHaveValue('Check my calendar'); + }); + + it('should check for provider settings before submitting task', async () => { + // Arrange + render( + + + + ); + + // Act + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: 'Submit this task' } }); + + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Assert - should check provider settings (via isE2EMode and getProviderSettings) + await waitFor(() => { + expect(mockAccomplish.isE2EMode).toHaveBeenCalled(); + }); + }); + + it('should open settings dialog when no provider is ready', async () => { + // Arrange - Set up mock to return no ready providers + mockAccomplish.getProviderSettings.mockResolvedValue({ + activeProviderId: null, + connectedProviders: {}, + debugMode: false, + }); + + render( + + + + ); + + // Act + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: 'Submit without provider' } }); + + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(screen.getByTestId('settings-dialog')).toBeInTheDocument(); + }); + }); + + it('should start task when API key exists', async () => { + // Arrange + const mockTask = createMockTask('task-123', 'My task', 'running'); + mockStartTask.mockResolvedValue(mockTask); + mockHasAnyApiKey.mockResolvedValue(true); + + render( + + + + ); + + // Act + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: 'My task' } }); + + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(mockStartTask).toHaveBeenCalled(); + }); + }); + + it('should not submit empty task', async () => { + // Arrange + render( + + + + ); + + // Act + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Assert - empty tasks return early, no provider check or task start + await waitFor(() => { + expect(mockAccomplish.isE2EMode).not.toHaveBeenCalled(); + expect(mockStartTask).not.toHaveBeenCalled(); + }); + }); + + it('should not submit whitespace-only task', async () => { + // Arrange + render( + + + + ); + + // Act + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: ' ' } }); + + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Assert - whitespace-only input should not trigger any API calls + await waitFor(() => { + expect(mockAccomplish.isE2EMode).not.toHaveBeenCalled(); + expect(mockStartTask).not.toHaveBeenCalled(); + }); + }); + + it('should execute task after configuring provider in settings', async () => { + // Arrange - No ready provider initially + mockAccomplish.getProviderSettings.mockResolvedValue({ + activeProviderId: null, + connectedProviders: {}, + debugMode: false, + }); + const mockTask = createMockTask('task-123', 'My task', 'running'); + mockStartTask.mockResolvedValue(mockTask); + + render( + + + + ); + + // Act - Submit to open settings + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: 'My task' } }); + + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Wait for dialog + await waitFor(() => { + expect(screen.getByTestId('settings-dialog')).toBeInTheDocument(); + }); + + // Simulate saving API key (which triggers onApiKeySaved callback) + const saveButton = screen.getByRole('button', { name: /save api key/i }); + fireEvent.click(saveButton); + + // Assert - Task should be started after provider is configured + await waitFor(() => { + expect(mockStartTask).toHaveBeenCalled(); + }); + }); + }); + + describe('loading state', () => { + it('should disable input when loading', () => { + // Arrange + mockStoreState.isLoading = true; + + // Act + render( + + + + ); + + // Assert + const textarea = screen.getByPlaceholderText(/describe a task/i); + expect(textarea).toBeDisabled(); + }); + + it('should disable submit button when loading', () => { + // Arrange + mockStoreState.isLoading = true; + + // Act + render( + + + + ); + + // Assert + const submitButton = screen.getByTitle('Submit'); + expect(submitButton).toBeDisabled(); + }); + + it('should not submit when already loading', async () => { + // Arrange + mockStoreState.isLoading = true; + + render( + + + + ); + + // The textarea is disabled, so we can't really type, but test submit + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + // Assert + await waitFor(() => { + expect(mockStartTask).not.toHaveBeenCalled(); + }); + }); + }); + + describe('example prompts', () => { + it('should populate input when example is clicked', async () => { + // Arrange + render( + + + + ); + + // Act - Click on Calendar Prep Notes example (expanded by default) + await waitFor(() => { + expect(screen.getByText('Calendar Prep Notes')).toBeInTheDocument(); + }); + const exampleButton = screen.getByText('Calendar Prep Notes').closest('button'); + expect(exampleButton).toBeInTheDocument(); + fireEvent.click(exampleButton!); + + // Assert - The textarea should now contain text related to the example + await waitFor(() => { + const textarea = screen.getByPlaceholderText(/describe a task/i) as HTMLTextAreaElement; + expect(textarea.value.length).toBeGreaterThan(0); + expect(textarea.value.toLowerCase()).toContain('calendar'); + }); + }); + + it('should be able to toggle example prompts visibility', async () => { + // Arrange + render( + + + + ); + + // Assert - Examples should be visible initially (expanded by default) + await waitFor(() => { + expect(screen.getByText('Calendar Prep Notes')).toBeInTheDocument(); + }); + + // Act - Toggle examples off + const toggleButton = screen.getByText(/example prompts/i).closest('button'); + fireEvent.click(toggleButton!); + + // Assert - Examples should be hidden now + await waitFor(() => { + expect(screen.queryByText('Calendar Prep Notes')).not.toBeInTheDocument(); + }); + + // Act - Toggle examples on again + fireEvent.click(toggleButton!); + + // Assert - Examples should be visible again + await waitFor(() => { + expect(screen.getByText('Calendar Prep Notes')).toBeInTheDocument(); + }); + }); + + it('should render all nine example use cases', async () => { + // Arrange & Act + render( + + + + ); + + // Assert - examples are expanded by default + const expectedExamples = [ + 'Calendar Prep Notes', + 'Inbox Promo Cleanup', + 'Competitor Pricing Deck', + 'Notion API Audit', + 'Staging vs Prod Visual Check', + 'Production Broken Links', + 'Portfolio Monitoring', + 'Job Application Automation', + 'Event Calendar Builder', + ]; + + await waitFor(() => { + expectedExamples.forEach(example => { + expect(screen.getByText(example)).toBeInTheDocument(); + }); + }); + }); + }); + + describe('settings dialog interaction', () => { + it('should close settings dialog without executing when cancelled', async () => { + // Arrange - No ready provider + mockAccomplish.getProviderSettings.mockResolvedValue({ + activeProviderId: null, + connectedProviders: {}, + debugMode: false, + }); + + render( + + + + ); + + // Act - Open settings via submit + const textarea = screen.getByPlaceholderText(/describe a task/i); + fireEvent.change(textarea, { target: { value: 'My task' } }); + + const submitButton = screen.getByTitle('Submit'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(screen.getByTestId('settings-dialog')).toBeInTheDocument(); + }); + + // Close without saving + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + // Assert + await waitFor(() => { + expect(screen.queryByTestId('settings-dialog')).not.toBeInTheDocument(); + expect(mockStartTask).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts new file mode 100644 index 000000000..135d5d2b5 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts @@ -0,0 +1,869 @@ +/** + * Integration tests for taskStore (Zustand) + * Tests store actions with mocked window.accomplish API + * @module __tests__/integration/renderer/taskStore.integration.test + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Task, TaskConfig, TaskStatus, TaskMessage, TaskResult } from '@accomplish/shared'; + +// Helper to create a mock task +function createMockTask(id: string, prompt: string = 'Test task', status: TaskStatus = 'pending'): Task { + return { + id, + prompt, + status, + messages: [], + createdAt: new Date().toISOString(), + }; +} + +// Helper to create a mock message +function createMockMessage( + id: string, + type: 'assistant' | 'user' | 'tool' | 'system' = 'assistant', + content: string = 'Test message' +): TaskMessage { + return { + id, + type, + content, + timestamp: new Date().toISOString(), + }; +} + +// Mock accomplish API +const mockAccomplish = { + startTask: vi.fn(), + cancelTask: vi.fn(), + interruptTask: vi.fn(), + resumeSession: vi.fn(), + respondToPermission: vi.fn(), + listTasks: vi.fn(), + getTask: vi.fn(), + deleteTask: vi.fn(), + clearTaskHistory: vi.fn(), + logEvent: vi.fn().mockResolvedValue(undefined), + getSelectedModel: vi.fn().mockResolvedValue({ provider: 'anthropic', id: 'claude-3-opus' }), + getOllamaConfig: vi.fn().mockResolvedValue(null), + isE2EMode: vi.fn().mockResolvedValue(false), + getProviderSettings: vi.fn().mockResolvedValue({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + }), + // Provider settings methods + setActiveProvider: vi.fn().mockResolvedValue(undefined), + setConnectedProvider: vi.fn().mockResolvedValue(undefined), + removeConnectedProvider: vi.fn().mockResolvedValue(undefined), + setProviderDebugMode: vi.fn().mockResolvedValue(undefined), + validateApiKeyForProvider: vi.fn().mockResolvedValue({ valid: true }), + validateBedrockCredentials: vi.fn().mockResolvedValue({ valid: true }), + saveBedrockCredentials: vi.fn().mockResolvedValue(undefined), +}; + +// Mock the accomplish module +vi.mock('@/lib/accomplish', () => ({ + getAccomplish: () => mockAccomplish, +})); + +// Mock window.accomplish for global subscriptions +const mockOnTaskProgress = vi.fn(); +const mockOnTaskUpdate = vi.fn(); + +vi.stubGlobal('window', { + accomplish: { + onTaskProgress: mockOnTaskProgress, + onTaskUpdate: mockOnTaskUpdate, + }, +}); + +describe('taskStore Integration', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + afterEach(async () => { + // Reset store state + try { + const { useTaskStore } = await import('@/stores/taskStore'); + useTaskStore.setState({ + currentTask: null, + isLoading: false, + error: null, + tasks: [], + permissionRequest: null, + setupProgress: null, + setupProgressTaskId: null, + setupDownloadStep: 1, + }); + } catch { + // Store may not be loaded + } + }); + + describe('initial state', () => { + it('should have null currentTask initially', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask).toBeNull(); + }); + + it('should have isLoading as false initially', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + const state = useTaskStore.getState(); + + // Assert + expect(state.isLoading).toBe(false); + }); + + it('should have null error initially', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + const state = useTaskStore.getState(); + + // Assert + expect(state.error).toBeNull(); + }); + + it('should have empty tasks array initially', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + const state = useTaskStore.getState(); + + // Assert + expect(state.tasks).toEqual([]); + }); + + it('should have null permissionRequest initially', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + const state = useTaskStore.getState(); + + // Assert + expect(state.permissionRequest).toBeNull(); + }); + + it('should have setupDownloadStep as 1 initially', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + const state = useTaskStore.getState(); + + // Assert + expect(state.setupDownloadStep).toBe(1); + }); + }); + + describe('startTask', () => { + it('should call startTask API and update state on success', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const mockTask = createMockTask('task-123', 'Test prompt', 'running'); + mockAccomplish.startTask.mockResolvedValueOnce(mockTask); + + const config: TaskConfig = { prompt: 'Test prompt' }; + + // Act + const result = await useTaskStore.getState().startTask(config); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.startTask).toHaveBeenCalledWith(config); + expect(result).toEqual(mockTask); + expect(state.currentTask).toEqual(mockTask); + expect(state.isLoading).toBe(false); + expect(state.tasks).toContainEqual(mockTask); + }); + + it('should set isLoading to true for queued tasks', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const mockTask = createMockTask('task-123', 'Test prompt', 'queued'); + mockAccomplish.startTask.mockResolvedValueOnce(mockTask); + + // Act + await useTaskStore.getState().startTask({ prompt: 'Test prompt' }); + const state = useTaskStore.getState(); + + // Assert + expect(state.isLoading).toBe(true); + }); + + it('should set error state on failure', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + mockAccomplish.startTask.mockRejectedValueOnce(new Error('API Error')); + + // Act + const result = await useTaskStore.getState().startTask({ prompt: 'Test prompt' }); + const state = useTaskStore.getState(); + + // Assert + expect(result).toBeNull(); + expect(state.error).toBe('API Error'); + expect(state.isLoading).toBe(false); + }); + + it('should handle non-Error exceptions gracefully', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + mockAccomplish.startTask.mockRejectedValueOnce('String error'); + + // Act + const result = await useTaskStore.getState().startTask({ prompt: 'Test' }); + const state = useTaskStore.getState(); + + // Assert + expect(result).toBeNull(); + expect(state.error).toBe('Failed to start task'); + }); + + it('should add task to tasks list', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const mockTask = createMockTask('task-123', 'Test', 'running'); + mockAccomplish.startTask.mockResolvedValueOnce(mockTask); + + // Set existing tasks + useTaskStore.setState({ tasks: [createMockTask('existing-task')] }); + + // Act + await useTaskStore.getState().startTask({ prompt: 'Test' }); + const state = useTaskStore.getState(); + + // Assert + expect(state.tasks).toHaveLength(2); + expect(state.tasks[0].id).toBe('task-123'); // New task should be first + }); + + it('should update existing task if same ID', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const existingTask = createMockTask('task-123', 'Old prompt', 'pending'); + const updatedTask = createMockTask('task-123', 'New prompt', 'running'); + mockAccomplish.startTask.mockResolvedValueOnce(updatedTask); + + useTaskStore.setState({ tasks: [existingTask] }); + + // Act + await useTaskStore.getState().startTask({ prompt: 'New prompt', taskId: 'task-123' }); + const state = useTaskStore.getState(); + + // Assert + expect(state.tasks).toHaveLength(1); + expect(state.tasks[0].prompt).toBe('New prompt'); + }); + }); + + describe('sendFollowUp', () => { + it('should set error when no active task', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + await useTaskStore.getState().sendFollowUp('Follow up message'); + const state = useTaskStore.getState(); + + // Assert + expect(state.error).toBe('No active task to continue'); + }); + + it('should set error when task has no session', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const taskWithoutSession = createMockTask('task-123', 'Test', 'completed'); + useTaskStore.setState({ currentTask: taskWithoutSession }); + + // Act + await useTaskStore.getState().sendFollowUp('Follow up'); + const state = useTaskStore.getState(); + + // Assert + expect(state.error).toBe('No session to continue - please start a new task'); + }); + + it('should start fresh task for interrupted task without session', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const interruptedTask: Task = { + ...createMockTask('task-123', 'Original', 'interrupted'), + }; + const newTask = createMockTask('task-456', 'Fresh start', 'running'); + mockAccomplish.startTask.mockResolvedValueOnce(newTask); + + useTaskStore.setState({ currentTask: interruptedTask, tasks: [interruptedTask] }); + + // Act + await useTaskStore.getState().sendFollowUp('New message'); + + // Assert + expect(mockAccomplish.startTask).toHaveBeenCalled(); + }); + + it('should resume session when task has sessionId', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const taskWithSession: Task = { + ...createMockTask('task-123', 'Test', 'completed'), + sessionId: 'session-abc', + }; + const resumedTask = createMockTask('task-123', 'Test', 'running'); + mockAccomplish.resumeSession.mockResolvedValueOnce(resumedTask); + + useTaskStore.setState({ currentTask: taskWithSession, tasks: [taskWithSession] }); + + // Act + await useTaskStore.getState().sendFollowUp('Continue please'); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.resumeSession).toHaveBeenCalledWith('session-abc', 'Continue please', 'task-123'); + expect(state.currentTask?.status).toBe('running'); + }); + + it('should use result.sessionId if available', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const taskWithResultSession: Task = { + ...createMockTask('task-123', 'Test', 'completed'), + result: { status: 'success', sessionId: 'result-session-xyz' }, + }; + const resumedTask = createMockTask('task-123', 'Test', 'running'); + mockAccomplish.resumeSession.mockResolvedValueOnce(resumedTask); + + useTaskStore.setState({ currentTask: taskWithResultSession, tasks: [taskWithResultSession] }); + + // Act + await useTaskStore.getState().sendFollowUp('More work'); + + // Assert + expect(mockAccomplish.resumeSession).toHaveBeenCalledWith('result-session-xyz', 'More work', 'task-123'); + }); + + it('should add user message optimistically', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const taskWithSession: Task = { + ...createMockTask('task-123', 'Test', 'completed'), + sessionId: 'session-abc', + messages: [], + }; + mockAccomplish.resumeSession.mockResolvedValueOnce(createMockTask('task-123', 'Test', 'running')); + + useTaskStore.setState({ currentTask: taskWithSession, tasks: [taskWithSession] }); + + // Act + await useTaskStore.getState().sendFollowUp('User follow up'); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.messages).toHaveLength(1); + expect(state.currentTask?.messages[0].type).toBe('user'); + expect(state.currentTask?.messages[0].content).toBe('User follow up'); + }); + + it('should handle resumeSession failure', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const taskWithSession: Task = { + ...createMockTask('task-123', 'Test', 'completed'), + sessionId: 'session-abc', + }; + mockAccomplish.resumeSession.mockRejectedValueOnce(new Error('Resume failed')); + + useTaskStore.setState({ currentTask: taskWithSession, tasks: [taskWithSession] }); + + // Act + await useTaskStore.getState().sendFollowUp('Follow up'); + const state = useTaskStore.getState(); + + // Assert + expect(state.error).toBe('Resume failed'); + expect(state.currentTask?.status).toBe('failed'); + expect(state.isLoading).toBe(false); + }); + }); + + describe('cancelTask', () => { + it('should call cancelTask API and update status', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const runningTask = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: runningTask, tasks: [runningTask] }); + mockAccomplish.cancelTask.mockResolvedValueOnce(undefined); + + // Act + await useTaskStore.getState().cancelTask(); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.cancelTask).toHaveBeenCalledWith('task-123'); + expect(state.currentTask?.status).toBe('cancelled'); + expect(state.tasks[0].status).toBe('cancelled'); + }); + + it('should do nothing when no current task', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + await useTaskStore.getState().cancelTask(); + + // Assert + expect(mockAccomplish.cancelTask).not.toHaveBeenCalled(); + }); + }); + + describe('interruptTask', () => { + it('should call interruptTask API for running task', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const runningTask = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: runningTask }); + mockAccomplish.interruptTask.mockResolvedValueOnce(undefined); + + // Act + await useTaskStore.getState().interruptTask(); + + // Assert + expect(mockAccomplish.interruptTask).toHaveBeenCalledWith('task-123'); + }); + + it('should not call API for non-running task', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const completedTask = createMockTask('task-123', 'Test', 'completed'); + useTaskStore.setState({ currentTask: completedTask }); + + // Act + await useTaskStore.getState().interruptTask(); + + // Assert + expect(mockAccomplish.interruptTask).not.toHaveBeenCalled(); + }); + + it('should not change task status', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const runningTask = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: runningTask }); + mockAccomplish.interruptTask.mockResolvedValueOnce(undefined); + + // Act + await useTaskStore.getState().interruptTask(); + const state = useTaskStore.getState(); + + // Assert - status should remain 'running' (interrupt is handled by event) + expect(state.currentTask?.status).toBe('running'); + }); + }); + + describe('addTaskUpdateBatch', () => { + it('should add multiple messages in single update', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task, tasks: [task] }); + + const messages = [ + createMockMessage('msg-1', 'assistant', 'First'), + createMockMessage('msg-2', 'tool', 'Second'), + createMockMessage('msg-3', 'assistant', 'Third'), + ]; + + // Act + useTaskStore.getState().addTaskUpdateBatch({ taskId: 'task-123', messages }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.messages).toHaveLength(3); + expect(state.currentTask?.messages[0].content).toBe('First'); + expect(state.currentTask?.messages[1].content).toBe('Second'); + expect(state.currentTask?.messages[2].content).toBe('Third'); + }); + + it('should not update state if task ID does not match', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task }); + + // Act + useTaskStore.getState().addTaskUpdateBatch({ + taskId: 'different-task', + messages: [createMockMessage('msg-1')], + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.messages).toHaveLength(0); + }); + + it('should not update state if no current task', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + + // Act + useTaskStore.getState().addTaskUpdateBatch({ + taskId: 'task-123', + messages: [createMockMessage('msg-1')], + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask).toBeNull(); + }); + + it('should append to existing messages', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task: Task = { + ...createMockTask('task-123', 'Test', 'running'), + messages: [createMockMessage('existing', 'user', 'Existing')], + }; + useTaskStore.setState({ currentTask: task }); + + // Act + useTaskStore.getState().addTaskUpdateBatch({ + taskId: 'task-123', + messages: [createMockMessage('new', 'assistant', 'New')], + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.messages).toHaveLength(2); + expect(state.currentTask?.messages[0].content).toBe('Existing'); + expect(state.currentTask?.messages[1].content).toBe('New'); + }); + + it('should set isLoading to false after batch update', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task, isLoading: true }); + + // Act + useTaskStore.getState().addTaskUpdateBatch({ taskId: 'task-123', messages: [] }); + const state = useTaskStore.getState(); + + // Assert + expect(state.isLoading).toBe(false); + }); + }); + + describe('error state management', () => { + it('should clear error on successful task start', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + useTaskStore.setState({ error: 'Previous error' }); + mockAccomplish.startTask.mockResolvedValueOnce(createMockTask('task-123')); + + // Act + await useTaskStore.getState().startTask({ prompt: 'Test' }); + const state = useTaskStore.getState(); + + // Assert + expect(state.error).toBeNull(); + }); + + it('should clear error on successful follow up', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const taskWithSession: Task = { + ...createMockTask('task-123', 'Test', 'completed'), + sessionId: 'session-abc', + }; + useTaskStore.setState({ currentTask: taskWithSession, tasks: [taskWithSession], error: 'Previous error' }); + mockAccomplish.resumeSession.mockResolvedValueOnce(createMockTask('task-123', 'Test', 'running')); + + // Act + await useTaskStore.getState().sendFollowUp('Continue'); + const state = useTaskStore.getState(); + + // Assert + expect(state.error).toBeNull(); + }); + }); + + describe('loadTasks', () => { + it('should load tasks from API', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const mockTasks = [ + createMockTask('task-1'), + createMockTask('task-2'), + createMockTask('task-3'), + ]; + mockAccomplish.listTasks.mockResolvedValueOnce(mockTasks); + + // Act + await useTaskStore.getState().loadTasks(); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.listTasks).toHaveBeenCalled(); + expect(state.tasks).toEqual(mockTasks); + }); + }); + + describe('loadTaskById', () => { + it('should load specific task and set as current', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const mockTask = createMockTask('task-123', 'Loaded task'); + mockAccomplish.getTask.mockResolvedValueOnce(mockTask); + + // Act + await useTaskStore.getState().loadTaskById('task-123'); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.getTask).toHaveBeenCalledWith('task-123'); + expect(state.currentTask).toEqual(mockTask); + expect(state.error).toBeNull(); + }); + + it('should set error when task not found', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + mockAccomplish.getTask.mockResolvedValueOnce(null); + + // Act + await useTaskStore.getState().loadTaskById('non-existent'); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask).toBeNull(); + expect(state.error).toBe('Task not found'); + }); + }); + + describe('deleteTask', () => { + it('should delete task and remove from list', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const tasks = [ + createMockTask('task-1'), + createMockTask('task-2'), + createMockTask('task-3'), + ]; + useTaskStore.setState({ tasks }); + mockAccomplish.deleteTask.mockResolvedValueOnce(undefined); + + // Act + await useTaskStore.getState().deleteTask('task-2'); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.deleteTask).toHaveBeenCalledWith('task-2'); + expect(state.tasks).toHaveLength(2); + expect(state.tasks.find(t => t.id === 'task-2')).toBeUndefined(); + }); + }); + + describe('clearHistory', () => { + it('should clear all tasks', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + useTaskStore.setState({ tasks: [createMockTask('task-1'), createMockTask('task-2')] }); + mockAccomplish.clearTaskHistory.mockResolvedValueOnce(undefined); + + // Act + await useTaskStore.getState().clearHistory(); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.clearTaskHistory).toHaveBeenCalled(); + expect(state.tasks).toEqual([]); + }); + }); + + describe('reset', () => { + it('should reset task-related state but preserve tasks list', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const tasks = [createMockTask('task-1'), createMockTask('task-2')]; + useTaskStore.setState({ + currentTask: createMockTask('task-current'), + isLoading: true, + error: 'Some error', + tasks, + permissionRequest: { id: 'perm-1', taskId: 'task-1', type: 'file', message: 'Allow?' }, + setupProgress: 'Downloading...', + setupProgressTaskId: 'task-1', + setupDownloadStep: 2, + }); + + // Act + useTaskStore.getState().reset(); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask).toBeNull(); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + expect(state.permissionRequest).toBeNull(); + expect(state.setupProgress).toBeNull(); + expect(state.setupProgressTaskId).toBeNull(); + expect(state.setupDownloadStep).toBe(1); + // Tasks should be preserved + expect(state.tasks).toEqual(tasks); + }); + }); + + describe('respondToPermission', () => { + it('should call API and clear permission request', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + useTaskStore.setState({ + permissionRequest: { id: 'perm-1', taskId: 'task-1', type: 'file', message: 'Allow?' }, + }); + mockAccomplish.respondToPermission.mockResolvedValueOnce(undefined); + + const response = { permissionId: 'perm-1', granted: true }; + + // Act + await useTaskStore.getState().respondToPermission(response); + const state = useTaskStore.getState(); + + // Assert + expect(mockAccomplish.respondToPermission).toHaveBeenCalledWith(response); + expect(state.permissionRequest).toBeNull(); + }); + }); + + describe('updateTaskStatus', () => { + it('should update task status in tasks list and currentTask', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'queued'); + useTaskStore.setState({ currentTask: task, tasks: [task] }); + + // Act + useTaskStore.getState().updateTaskStatus('task-123', 'running'); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.status).toBe('running'); + expect(state.tasks[0].status).toBe('running'); + }); + + it('should only update tasks list when currentTask does not match', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const currentTask = createMockTask('task-current', 'Current', 'running'); + const otherTask = createMockTask('task-other', 'Other', 'queued'); + useTaskStore.setState({ currentTask, tasks: [currentTask, otherTask] }); + + // Act + useTaskStore.getState().updateTaskStatus('task-other', 'running'); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.status).toBe('running'); // Unchanged + expect(state.tasks.find(t => t.id === 'task-other')?.status).toBe('running'); + }); + }); + + describe('addTaskUpdate - complete event', () => { + it('should set completed status for success result', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task, tasks: [task] }); + + // Act + useTaskStore.getState().addTaskUpdate({ + type: 'complete', + taskId: 'task-123', + result: { status: 'success' }, + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.status).toBe('completed'); + expect(state.tasks[0].status).toBe('completed'); + }); + + it('should set interrupted status for interrupted result', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task, tasks: [task] }); + + // Act + useTaskStore.getState().addTaskUpdate({ + type: 'complete', + taskId: 'task-123', + result: { status: 'interrupted' }, + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.status).toBe('interrupted'); + }); + + it('should set failed status for error result', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task, tasks: [task] }); + + // Act + useTaskStore.getState().addTaskUpdate({ + type: 'complete', + taskId: 'task-123', + result: { status: 'error', error: 'Something went wrong' }, + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.status).toBe('failed'); + }); + + it('should preserve sessionId from result', async () => { + // Arrange + const { useTaskStore } = await import('@/stores/taskStore'); + const task = createMockTask('task-123', 'Test', 'running'); + useTaskStore.setState({ currentTask: task, tasks: [task] }); + + const result: TaskResult = { status: 'success', sessionId: 'session-from-result' }; + + // Act + useTaskStore.getState().addTaskUpdate({ + type: 'complete', + taskId: 'task-123', + result, + }); + const state = useTaskStore.getState(); + + // Assert + expect(state.currentTask?.sessionId).toBe('session-from-result'); + expect(state.currentTask?.result).toEqual(result); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts new file mode 100644 index 000000000..bb49b9c41 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// We need to test the module in isolation, so we'll import it dynamically +// to reset the cache between tests + +describe('config.ts', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv }; + // Clear module cache to reset cachedConfig + vi.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + vi.resetModules(); + }); + + describe('getDesktopConfig()', () => { + describe('default configuration', () => { + it('should return default API URL when ACCOMPLISH_API_URL is not set', async () => { + // Arrange + delete process.env.ACCOMPLISH_API_URL; + + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config.apiUrl).toBe('https://lite.accomplish.ai'); + }); + + it('should return default API URL when ACCOMPLISH_API_URL is undefined', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = undefined; + + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config.apiUrl).toBe('https://lite.accomplish.ai'); + }); + }); + + describe('custom API URL parsing', () => { + it('should use custom HTTPS API URL from environment', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'https://custom.example.com'; + + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config.apiUrl).toBe('https://custom.example.com'); + }); + + it('should use custom HTTP API URL from environment', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'http://localhost:3000'; + + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config.apiUrl).toBe('http://localhost:3000'); + }); + + it('should accept URL with path', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'https://api.example.com/v1'; + + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config.apiUrl).toBe('https://api.example.com/v1'); + }); + + it('should accept URL with port', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'https://api.example.com:8443'; + + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config.apiUrl).toBe('https://api.example.com:8443'); + }); + + it('should throw error for invalid URL format', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'not-a-url'; + + // Act & Assert + const { getDesktopConfig } = await import('../../src/main/config'); + expect(() => getDesktopConfig()).toThrow('Invalid desktop configuration'); + }); + + it('should throw error for URL without protocol', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'example.com'; + + // Act & Assert + const { getDesktopConfig } = await import('../../src/main/config'); + expect(() => getDesktopConfig()).toThrow('Invalid desktop configuration'); + }); + + it('should throw error for empty string URL (invalid url)', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = ''; + + // Act & Assert + // Empty string is an invalid URL and throws an error + const { getDesktopConfig } = await import('../../src/main/config'); + expect(() => getDesktopConfig()).toThrow('Invalid desktop configuration'); + }); + }); + + describe('config caching behavior', () => { + it('should cache config and return same result on multiple calls', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'https://first.example.com'; + const { getDesktopConfig } = await import('../../src/main/config'); + + // Act + const config1 = getDesktopConfig(); + + // Change env after first call + process.env.ACCOMPLISH_API_URL = 'https://second.example.com'; + const config2 = getDesktopConfig(); + + // Assert - should return cached value + expect(config1).toBe(config2); + expect(config1.apiUrl).toBe('https://first.example.com'); + }); + + it('should return identical object reference from cache', async () => { + // Arrange + const { getDesktopConfig } = await import('../../src/main/config'); + + // Act + const config1 = getDesktopConfig(); + const config2 = getDesktopConfig(); + + // Assert + expect(config1).toBe(config2); + }); + + it('should reset cache when module is reloaded', async () => { + // Arrange + process.env.ACCOMPLISH_API_URL = 'https://first.example.com'; + const mod1 = await import('../../src/main/config'); + const config1 = mod1.getDesktopConfig(); + + // Reset modules and change env + vi.resetModules(); + process.env.ACCOMPLISH_API_URL = 'https://second.example.com'; + + // Act + const mod2 = await import('../../src/main/config'); + const config2 = mod2.getDesktopConfig(); + + // Assert + expect(config1.apiUrl).toBe('https://first.example.com'); + expect(config2.apiUrl).toBe('https://second.example.com'); + }); + }); + + describe('config structure', () => { + it('should return object with apiUrl property', async () => { + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(config).toHaveProperty('apiUrl'); + expect(typeof config.apiUrl).toBe('string'); + }); + + it('should not have extra properties beyond apiUrl', async () => { + // Act + const { getDesktopConfig } = await import('../../src/main/config'); + const config = getDesktopConfig(); + + // Assert + expect(Object.keys(config)).toEqual(['apiUrl']); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts new file mode 100644 index 000000000..3905889f2 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts @@ -0,0 +1,784 @@ +/** + * Unit tests for pure utility functions extracted from handlers.ts + * + * Note: The handlers.ts file contains mostly IPC handler registration code + * that requires Electron mocking. These tests focus on the pure utility + * functions that can be tested in isolation. + * + * Functions tested: + * - sanitizeString (text validation/sanitization) + * - extractScreenshots (base64 image extraction) + * - sanitizeToolOutput (output cleaning) + * - ID generation patterns + */ + +import { describe, it, expect } from 'vitest'; + +// Since these functions are not exported from handlers.ts, +// we'll recreate them here for testing purposes. +// In a real codebase, these would be extracted to a separate utils file. + +const MAX_TEXT_LENGTH = 8000; + +/** + * Sanitize and validate string input + */ +function sanitizeString(input: unknown, field: string, maxLength = MAX_TEXT_LENGTH): string { + if (typeof input !== 'string') { + throw new Error(`${field} must be a string`); + } + const trimmed = input.trim(); + if (!trimmed) { + throw new Error(`${field} is required`); + } + if (trimmed.length > maxLength) { + throw new Error(`${field} exceeds maximum length`); + } + return trimmed; +} + +/** + * Create a task ID with timestamp and random suffix + */ +function createTaskId(): string { + return `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Create a message ID with timestamp and random suffix + */ +function createMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Extract base64 screenshots from tool output + */ +function extractScreenshots(output: string): { + cleanedText: string; + attachments: Array<{ type: 'screenshot' | 'json'; data: string; label?: string }>; +} { + const attachments: Array<{ type: 'screenshot' | 'json'; data: string; label?: string }> = []; + + // Match data URLs (data:image/png;base64,...) + const dataUrlRegex = /data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+/g; + let match; + while ((match = dataUrlRegex.exec(output)) !== null) { + attachments.push({ + type: 'screenshot', + data: match[0], + label: 'Browser screenshot', + }); + } + + // Also check for raw base64 PNG (starts with iVBORw0) + const rawBase64Regex = /(? 100) { + attachments.push({ + type: 'screenshot', + data: `data:image/png;base64,${base64Data}`, + label: 'Browser screenshot', + }); + } + } + + // Clean the text + let cleanedText = output + .replace(dataUrlRegex, '[Screenshot captured]') + .replace(rawBase64Regex, '[Screenshot captured]'); + + cleanedText = cleanedText + .replace(/"[Screenshot captured]"/g, '"[Screenshot]"') + .replace(/\[Screenshot captured\]\[Screenshot captured\]/g, '[Screenshot captured]'); + + return { cleanedText, attachments }; +} + +/** + * Sanitize tool output to remove technical details + */ +function sanitizeToolOutput(text: string, isError: boolean): string { + let result = text; + + // Strip ANSI escape codes + result = result.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); + result = result.replace(/\x1B\[2m|\x1B\[22m|\x1B\[0m/g, ''); + + // Remove WebSocket URLs + result = result.replace(/ws:\/\/[^\s\]]+/g, '[connection]'); + + // Remove "Call log:" sections + result = result.replace(/\s*Call log:[\s\S]*/i, ''); + + if (isError) { + // Timeout errors + const timeoutMatch = result.match(/timed? ?out after (\d+)ms/i); + if (timeoutMatch) { + const seconds = Math.round(parseInt(timeoutMatch[1]) / 1000); + return `Timed out after ${seconds}s`; + } + + // Protocol errors + const protocolMatch = result.match(/Protocol error \([^)]+\):\s*(.+)/i); + if (protocolMatch) { + result = protocolMatch[1].trim(); + } + + result = result.replace(/^Error executing code:\s*/i, ''); + result = result.replace(/browserType\.connectOverCDP:\s*/i, ''); + result = result.replace(/\s+at\s+.+/g, ''); + result = result.replace(/\w+Error:\s*/g, ''); + } + + return result.trim(); +} + +describe('handlers-utils', () => { + describe('sanitizeString()', () => { + describe('valid inputs', () => { + it('should return trimmed string for valid input', () => { + // Act + const result = sanitizeString(' hello world ', 'test'); + + // Assert + expect(result).toBe('hello world'); + }); + + it('should accept string at max length', () => { + // Arrange + const longString = 'a'.repeat(100); + + // Act + const result = sanitizeString(longString, 'test', 100); + + // Assert + expect(result).toBe(longString); + }); + + it('should accept single character string', () => { + // Act + const result = sanitizeString('x', 'test'); + + // Assert + expect(result).toBe('x'); + }); + + it('should handle multiline strings', () => { + // Act + const result = sanitizeString('line1\nline2\nline3', 'test'); + + // Assert + expect(result).toBe('line1\nline2\nline3'); + }); + + it('should handle special characters', () => { + // Act + const result = sanitizeString('!@#$%^&*()', 'test'); + + // Assert + expect(result).toBe('!@#$%^&*()'); + }); + + it('should handle unicode characters', () => { + // Act + const result = sanitizeString('Hello World', 'test'); + + // Assert + expect(result).toBe('Hello World'); + }); + }); + + describe('invalid inputs', () => { + it('should throw error for non-string (number)', () => { + // Act & Assert + expect(() => sanitizeString(123, 'field')).toThrow('field must be a string'); + }); + + it('should throw error for non-string (object)', () => { + // Act & Assert + expect(() => sanitizeString({}, 'field')).toThrow('field must be a string'); + }); + + it('should throw error for non-string (array)', () => { + // Act & Assert + expect(() => sanitizeString(['a', 'b'], 'field')).toThrow('field must be a string'); + }); + + it('should throw error for non-string (null)', () => { + // Act & Assert + expect(() => sanitizeString(null, 'field')).toThrow('field must be a string'); + }); + + it('should throw error for non-string (undefined)', () => { + // Act & Assert + expect(() => sanitizeString(undefined, 'field')).toThrow('field must be a string'); + }); + + it('should throw error for non-string (boolean)', () => { + // Act & Assert + expect(() => sanitizeString(true, 'field')).toThrow('field must be a string'); + }); + + it('should throw error for empty string', () => { + // Act & Assert + expect(() => sanitizeString('', 'field')).toThrow('field is required'); + }); + + it('should throw error for whitespace-only string', () => { + // Act & Assert + expect(() => sanitizeString(' \t\n ', 'field')).toThrow('field is required'); + }); + + it('should throw error for string exceeding max length', () => { + // Arrange + const longString = 'a'.repeat(101); + + // Act & Assert + expect(() => sanitizeString(longString, 'field', 100)).toThrow( + 'field exceeds maximum length' + ); + }); + + it('should use field name in error message', () => { + // Act & Assert + expect(() => sanitizeString(123, 'customField')).toThrow('customField must be a string'); + expect(() => sanitizeString('', 'anotherField')).toThrow('anotherField is required'); + expect(() => sanitizeString('abc', 'lengthField', 2)).toThrow( + 'lengthField exceeds maximum length' + ); + }); + }); + + describe('max length parameter', () => { + it('should use default max length when not specified', () => { + // Arrange + const longString = 'a'.repeat(MAX_TEXT_LENGTH); + + // Act + const result = sanitizeString(longString, 'test'); + + // Assert + expect(result.length).toBe(MAX_TEXT_LENGTH); + }); + + it('should use custom max length', () => { + // Arrange + const customMax = 50; + + // Act + const result = sanitizeString('a'.repeat(customMax), 'test', customMax); + + // Assert + expect(result.length).toBe(customMax); + }); + + it('should throw when exceeding custom max length', () => { + // Act & Assert + expect(() => sanitizeString('a'.repeat(51), 'test', 50)).toThrow( + 'exceeds maximum length' + ); + }); + }); + }); + + describe('ID generation', () => { + describe('createTaskId()', () => { + it('should start with task_ prefix', () => { + // Act + const id = createTaskId(); + + // Assert + expect(id).toMatch(/^task_/); + }); + + it('should include timestamp', () => { + // Arrange + const before = Date.now(); + + // Act + const id = createTaskId(); + + // Assert + const after = Date.now(); + const parts = id.split('_'); + const timestamp = parseInt(parts[1]); + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + + it('should include random suffix', () => { + // Act + const id = createTaskId(); + + // Assert + const parts = id.split('_'); + expect(parts[2]).toMatch(/^[a-z0-9]+$/); + expect(parts[2].length).toBeGreaterThanOrEqual(1); + }); + + it('should generate unique IDs', () => { + // Arrange + const ids = new Set(); + + // Act + for (let i = 0; i < 1000; i++) { + ids.add(createTaskId()); + } + + // Assert + expect(ids.size).toBe(1000); + }); + + it('should match expected format pattern', () => { + // Act + const id = createTaskId(); + + // Assert + expect(id).toMatch(/^task_\d+_[a-z0-9]+$/); + }); + }); + + describe('createMessageId()', () => { + it('should start with msg_ prefix', () => { + // Act + const id = createMessageId(); + + // Assert + expect(id).toMatch(/^msg_/); + }); + + it('should include timestamp', () => { + // Arrange + const before = Date.now(); + + // Act + const id = createMessageId(); + + // Assert + const after = Date.now(); + const parts = id.split('_'); + const timestamp = parseInt(parts[1]); + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + + it('should generate unique IDs', () => { + // Arrange + const ids = new Set(); + + // Act + for (let i = 0; i < 1000; i++) { + ids.add(createMessageId()); + } + + // Assert + expect(ids.size).toBe(1000); + }); + + it('should match expected format pattern', () => { + // Act + const id = createMessageId(); + + // Assert + expect(id).toMatch(/^msg_\d+_[a-z0-9]+$/); + }); + }); + }); + + describe('extractScreenshots()', () => { + describe('data URL extraction', () => { + it('should extract PNG data URL', () => { + // Arrange + const output = 'Here is the screenshot: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== done'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].type).toBe('screenshot'); + expect(result.attachments[0].data).toContain('data:image/png;base64,'); + expect(result.attachments[0].label).toBe('Browser screenshot'); + }); + + it('should extract JPEG data URL', () => { + // Arrange + const output = 'Image: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD end'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].data).toContain('data:image/jpeg;base64,'); + }); + + it('should extract WebP data URL', () => { + // Arrange + const output = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAQAcJaQAA3AA/v3AgAA='; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].data).toContain('data:image/webp;base64,'); + }); + + it('should extract multiple data URLs', () => { + // Arrange + const output = 'First: data:image/png;base64,AAAA Second: data:image/jpeg;base64,BBBB end'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments).toHaveLength(2); + }); + + it('should clean data URLs from text', () => { + // Arrange + const output = 'Before data:image/png;base64,AAAA after'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.cleanedText).toContain('[Screenshot captured]'); + expect(result.cleanedText).not.toContain('data:image'); + }); + }); + + describe('raw base64 PNG extraction', () => { + it('should extract raw base64 PNG starting with iVBORw0', () => { + // Arrange - Create a string that looks like raw base64 PNG (100+ chars) + const base64Png = 'iVBORw0' + 'A'.repeat(150); + const output = `Screenshot: "${base64Png}" end`; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments.length).toBeGreaterThanOrEqual(1); + const pngAttachment = result.attachments.find((a) => a.data.includes('iVBORw0')); + expect(pngAttachment).toBeDefined(); + expect(pngAttachment?.data).toContain('data:image/png;base64,'); + }); + + it('should not extract short base64 strings', () => { + // Arrange - Less than 100 chars after iVBORw0 + const output = 'Short: iVBORw0shortdata end'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments).toHaveLength(0); + }); + }); + + describe('text cleaning', () => { + it('should remove duplicate screenshot placeholders', () => { + // Arrange + const output = 'data:image/png;base64,AAA data:image/png;base64,BBB'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.cleanedText).not.toContain('[Screenshot captured][Screenshot captured]'); + }); + + it('should handle JSON-wrapped screenshots', () => { + // Arrange + const output = '{"image": "data:image/png;base64,AAA"}'; + + // Act + const result = extractScreenshots(output); + + // Assert + // The replacement creates "[Screenshot captured]" first, then quoted versions + // become "[Screenshot]" only if they match the exact pattern + expect(result.cleanedText).toContain('[Screenshot captured]'); + }); + + it('should return empty attachments for output without images', () => { + // Arrange + const output = 'Just some plain text without any images'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.attachments).toHaveLength(0); + expect(result.cleanedText).toBe(output); + }); + + it('should preserve non-image content', () => { + // Arrange + const output = 'Start data:image/png;base64,AAA middle data:image/jpeg;base64,BBB end'; + + // Act + const result = extractScreenshots(output); + + // Assert + expect(result.cleanedText).toContain('Start'); + expect(result.cleanedText).toContain('middle'); + expect(result.cleanedText).toContain('end'); + }); + }); + }); + + describe('sanitizeToolOutput()', () => { + describe('ANSI escape code removal', () => { + it('should strip basic ANSI color codes', () => { + // Arrange + const output = '\x1b[31mRed text\x1b[0m'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Red text'); + expect(result).not.toContain('\x1b'); + }); + + it('should strip complex ANSI sequences', () => { + // Arrange + const output = '\x1b[1;32;40mBold green on black\x1b[0m'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Bold green on black'); + }); + + it('should strip dim/bold toggle codes', () => { + // Arrange + const output = '\x1b[2mdimmed\x1b[22m normal \x1b[0m'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('dimmed normal'); + }); + + it('should handle multiple ANSI sequences', () => { + // Arrange + const output = '\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Red Green Blue'); + }); + }); + + describe('WebSocket URL removal', () => { + it('should replace WebSocket URLs with [connection]', () => { + // Arrange + const output = 'Connected to ws://localhost:9222/devtools/browser/abc123'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Connected to [connection]'); + expect(result).not.toContain('ws://'); + }); + + it('should handle multiple WebSocket URLs', () => { + // Arrange + const output = 'URL1: ws://host1:1234 URL2: ws://host2:5678/path'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toContain('[connection]'); + expect(result).not.toContain('ws://'); + }); + }); + + describe('Call log removal', () => { + it('should remove Call log section and everything after', () => { + // Arrange + const output = 'Important output\nCall log:\n- step 1\n- step 2\n- step 3'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Important output'); + expect(result).not.toContain('Call log'); + expect(result).not.toContain('step 1'); + }); + + it('should be case insensitive for Call log', () => { + // Arrange + const output = 'Output\nCALL LOG:\nstuff'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Output'); + }); + }); + + describe('error mode processing', () => { + it('should simplify timeout errors', () => { + // Arrange + const output = 'TimeoutError: Operation timed out after 30000ms waiting for selector'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('Timed out after 30s'); + }); + + it('should handle various timeout formats', () => { + // Arrange + const output1 = 'timeout after 5000ms'; + const output2 = 'timedout after 10000ms'; + + // Act + const result1 = sanitizeToolOutput(output1, true); + const result2 = sanitizeToolOutput(output2, true); + + // Assert + expect(result1).toBe('Timed out after 5s'); + expect(result2).toBe('Timed out after 10s'); + }); + + it('should extract message from Protocol error', () => { + // Arrange + const output = 'Protocol error (Runtime.callFunctionOn): Target closed.'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('Target closed.'); + expect(result).not.toContain('Protocol error'); + }); + + it('should remove Error executing code prefix', () => { + // Arrange + const output = 'Error executing code: Something went wrong'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('Something went wrong'); + }); + + it('should remove browserType.connectOverCDP prefix', () => { + // Arrange + const output = 'browserType.connectOverCDP: Connection refused'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('Connection refused'); + }); + + it('should remove stack traces', () => { + // Arrange + const output = 'Error message\n at Function.run (/path/to/file.js:10:5)\n at async Context.'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('Error message'); + expect(result).not.toContain('at Function'); + expect(result).not.toContain('/path/to'); + }); + + it('should remove error class names', () => { + // Arrange + const output = 'CodeExecutionTimeoutError: The operation took too long'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('The operation took too long'); + expect(result).not.toContain('Error:'); + }); + + it('should not process error-specific patterns when isError is false', () => { + // Arrange + const output = 'Error executing code: This should remain'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Error executing code: This should remain'); + }); + }); + + describe('trimming', () => { + it('should trim whitespace from result', () => { + // Arrange + const output = ' Output with spaces '; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Output with spaces'); + }); + + it('should handle empty string', () => { + // Act + const result = sanitizeToolOutput('', false); + + // Assert + expect(result).toBe(''); + }); + + it('should handle whitespace-only string', () => { + // Act + const result = sanitizeToolOutput(' \t\n ', false); + + // Assert + expect(result).toBe(''); + }); + }); + + describe('complex scenarios', () => { + it('should handle combined ANSI codes, URLs, and call logs', () => { + // Arrange + const output = '\x1b[32mConnected to ws://localhost:9222/debug\x1b[0m\nDoing work...\nCall log:\n- internal step'; + + // Act + const result = sanitizeToolOutput(output, false); + + // Assert + expect(result).toBe('Connected to [connection]\nDoing work...'); + }); + + it('should handle error mode with multiple cleanup patterns', () => { + // Arrange + const output = '\x1b[31mError executing code: SomeError: timed out after 5000ms\x1b[0m\n at something\nCall log:\n- step'; + + // Act + const result = sanitizeToolOutput(output, true); + + // Assert + expect(result).toBe('Timed out after 5s'); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts new file mode 100644 index 000000000..807b1ebfa --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts @@ -0,0 +1,617 @@ +import { describe, it, expect } from 'vitest'; +import { + validate, + normalizeIpcError, + taskConfigSchema, + permissionResponseSchema, + resumeSessionSchema, +} from '../../../src/main/ipc/validation'; +import { z } from 'zod'; + +describe('validation.ts', () => { + describe('validate()', () => { + const testSchema = z.object({ + name: z.string().min(1, 'Name is required'), + age: z.number().positive('Age must be positive'), + }); + + describe('when given valid payloads', () => { + it('should return the parsed data for valid input', () => { + // Arrange + const payload = { name: 'Alice', age: 30 }; + + // Act + const result = validate(testSchema, payload); + + // Assert + expect(result).toEqual({ name: 'Alice', age: 30 }); + }); + + it('should handle schema with optional fields', () => { + // Arrange + const schemaWithOptional = z.object({ + required: z.string(), + optional: z.string().optional(), + }); + const payload = { required: 'value' }; + + // Act + const result = validate(schemaWithOptional, payload); + + // Assert + expect(result).toEqual({ required: 'value' }); + }); + + it('should handle schema with default values', () => { + // Arrange + const schemaWithDefault = z.object({ + value: z.string().default('default'), + }); + const payload = {}; + + // Act + const result = validate(schemaWithDefault, payload); + + // Assert + expect(result).toEqual({ value: 'default' }); + }); + }); + + describe('when given invalid payloads', () => { + it('should throw an error for missing required fields', () => { + // Arrange + const payload = { age: 30 }; + + // Act & Assert + // Note: Zod returns "Required" for missing fields by default + expect(() => validate(testSchema, payload)).toThrow('Invalid payload: Required'); + }); + + it('should throw an error for wrong types', () => { + // Arrange + const payload = { name: 'Alice', age: 'thirty' }; + + // Act & Assert + expect(() => validate(testSchema, payload)).toThrow('Invalid payload:'); + }); + + it('should throw an error for validation constraints', () => { + // Arrange + const payload = { name: 'Alice', age: -5 }; + + // Act & Assert + expect(() => validate(testSchema, payload)).toThrow('Invalid payload: Age must be positive'); + }); + + it('should concatenate multiple error messages with semicolons', () => { + // Arrange + const payload = { name: '', age: -5 }; + + // Act & Assert + expect(() => validate(testSchema, payload)).toThrow('Invalid payload:'); + try { + validate(testSchema, payload); + } catch (error) { + expect((error as Error).message).toContain(';'); + } + }); + + it('should throw for null payload', () => { + // Act & Assert + expect(() => validate(testSchema, null)).toThrow('Invalid payload:'); + }); + + it('should throw for undefined payload', () => { + // Act & Assert + expect(() => validate(testSchema, undefined)).toThrow('Invalid payload:'); + }); + }); + }); + + describe('normalizeIpcError()', () => { + it('should return the same Error instance if given an Error', () => { + // Arrange + const error = new Error('Original error'); + + // Act + const result = normalizeIpcError(error); + + // Assert + expect(result).toBe(error); + expect(result.message).toBe('Original error'); + }); + + it('should wrap a string in an Error', () => { + // Arrange + const error = 'String error message'; + + // Act + const result = normalizeIpcError(error); + + // Assert + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('String error message'); + }); + + it('should return "Unknown IPC error" for null', () => { + // Act + const result = normalizeIpcError(null); + + // Assert + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('Unknown IPC error'); + }); + + it('should return "Unknown IPC error" for undefined', () => { + // Act + const result = normalizeIpcError(undefined); + + // Assert + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('Unknown IPC error'); + }); + + it('should return "Unknown IPC error" for objects', () => { + // Arrange + const error = { message: 'Object error', code: 123 }; + + // Act + const result = normalizeIpcError(error); + + // Assert + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('Unknown IPC error'); + }); + + it('should return "Unknown IPC error" for numbers', () => { + // Act + const result = normalizeIpcError(42); + + // Assert + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('Unknown IPC error'); + }); + + it('should return "Unknown IPC error" for boolean', () => { + // Act + const result = normalizeIpcError(false); + + // Assert + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe('Unknown IPC error'); + }); + + it('should preserve Error subclass types', () => { + // Arrange + class CustomError extends Error { + code: number; + constructor(message: string, code: number) { + super(message); + this.code = code; + } + } + const error = new CustomError('Custom error', 500); + + // Act + const result = normalizeIpcError(error); + + // Assert + expect(result).toBe(error); + expect(result).toBeInstanceOf(CustomError); + expect((result as CustomError).code).toBe(500); + }); + }); + + describe('taskConfigSchema', () => { + describe('valid payloads', () => { + it('should accept minimal valid config with prompt only', () => { + // Arrange + const config = { prompt: 'Do something' }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.prompt).toBe('Do something'); + } + }); + + it('should accept full config with all optional fields', () => { + // Arrange + const config = { + prompt: 'Create a file', + taskId: 'task_123', + workingDirectory: '/home/user', + allowedTools: ['read', 'write'], + systemPromptAppend: 'Be concise', + outputSchema: { type: 'object' }, + sessionId: 'session_abc', + chrome: true, + }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(config); + } + }); + + it('should accept empty arrays for allowedTools', () => { + // Arrange + const config = { prompt: 'Test', allowedTools: [] }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + }); + + it('should accept chrome as false', () => { + // Arrange + const config = { prompt: 'Test', chrome: false }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.chrome).toBe(false); + } + }); + }); + + describe('invalid payloads', () => { + it('should reject empty prompt', () => { + // Arrange + const config = { prompt: '' }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Prompt is required'); + } + }); + + it('should reject missing prompt', () => { + // Arrange + const config = {}; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + }); + + it('should accept prompt with only whitespace (min(1) allows whitespace)', () => { + // Arrange + const config = { prompt: ' ' }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + // Note: z.string().min(1) only checks length, not trimmed content + // The sanitization of whitespace-only strings happens in validateTaskConfig() + expect(result.success).toBe(true); + }); + + it('should reject non-string prompt', () => { + // Arrange + const config = { prompt: 123 }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + }); + + it('should reject non-array allowedTools', () => { + // Arrange + const config = { prompt: 'Test', allowedTools: 'read,write' }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + }); + + it('should reject non-boolean chrome', () => { + // Arrange + const config = { prompt: 'Test', chrome: 'yes' }; + + // Act + const result = taskConfigSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + }); + }); + }); + + describe('permissionResponseSchema', () => { + describe('valid payloads', () => { + it('should accept minimal allow response', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + decision: 'allow', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(true); + }); + + it('should accept minimal deny response', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + decision: 'deny', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(true); + }); + + it('should accept response with message', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + decision: 'allow', + message: 'User approved', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.message).toBe('User approved'); + } + }); + + it('should accept response with selectedOptions', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + decision: 'allow', + selectedOptions: ['option1', 'option2'], + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.selectedOptions).toEqual(['option1', 'option2']); + } + }); + }); + + describe('invalid payloads', () => { + it('should reject empty requestId', () => { + // Arrange + const response = { + requestId: '', + taskId: 'task_456', + decision: 'allow', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Request ID is required'); + } + }); + + it('should reject empty taskId', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: '', + decision: 'allow', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Task ID is required'); + } + }); + + it('should reject invalid decision', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + decision: 'maybe', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(false); + }); + + it('should reject missing decision', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(false); + }); + + it('should reject non-array selectedOptions', () => { + // Arrange + const response = { + requestId: 'req_123', + taskId: 'task_456', + decision: 'allow', + selectedOptions: 'option1,option2', + }; + + // Act + const result = permissionResponseSchema.safeParse(response); + + // Assert + expect(result.success).toBe(false); + }); + }); + }); + + describe('resumeSessionSchema', () => { + describe('valid payloads', () => { + it('should accept minimal resume config', () => { + // Arrange + const config = { + sessionId: 'session_abc', + prompt: 'Continue the task', + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(config); + } + }); + + it('should accept resume config with existingTaskId', () => { + // Arrange + const config = { + sessionId: 'session_abc', + prompt: 'Continue the task', + existingTaskId: 'task_123', + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.existingTaskId).toBe('task_123'); + } + }); + + it('should accept resume config with chrome flag', () => { + // Arrange + const config = { + sessionId: 'session_abc', + prompt: 'Continue the task', + chrome: true, + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.chrome).toBe(true); + } + }); + }); + + describe('invalid payloads', () => { + it('should reject empty sessionId', () => { + // Arrange + const config = { + sessionId: '', + prompt: 'Continue', + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Session ID is required'); + } + }); + + it('should reject empty prompt', () => { + // Arrange + const config = { + sessionId: 'session_abc', + prompt: '', + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Prompt is required'); + } + }); + + it('should reject missing sessionId', () => { + // Arrange + const config = { + prompt: 'Continue', + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + }); + + it('should reject missing prompt', () => { + // Arrange + const config = { + sessionId: 'session_abc', + }; + + // Act + const result = resumeSessionSchema.safeParse(config); + + // Assert + expect(result.success).toBe(false); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts new file mode 100644 index 000000000..9554c46a6 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts @@ -0,0 +1,692 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StreamParser } from '../../../src/main/opencode/stream-parser'; +import type { OpenCodeMessage } from '@accomplish/shared'; + +describe('StreamParser', () => { + let parser: StreamParser; + let messageHandler: ReturnType; + let errorHandler: ReturnType; + + beforeEach(() => { + parser = new StreamParser(); + messageHandler = vi.fn(); + errorHandler = vi.fn(); + parser.on('message', messageHandler); + parser.on('error', errorHandler); + // Suppress console.log/error during tests + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + parser.removeAllListeners(); + vi.restoreAllMocks(); + }); + + describe('feed() with complete JSON lines', () => { + it('should parse a single complete JSON line', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Hello world', + }, + }; + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should parse multiple JSON lines in a single feed', () => { + // Arrange + const message1: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'First message', + }, + }; + const message2: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_2', + sessionID: 'session_1', + messageID: 'msg_2', + type: 'text', + text: 'Second message', + }, + }; + + // Act + parser.feed(JSON.stringify(message1) + '\n' + JSON.stringify(message2) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(2); + expect(messageHandler).toHaveBeenNthCalledWith(1, message1); + expect(messageHandler).toHaveBeenNthCalledWith(2, message2); + }); + + it('should handle step_start message type', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'step_start', + part: { + id: 'step_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'step-start', + }, + }; + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should handle tool_call message type', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'tool_call', + part: { + id: 'tool_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'tool-call', + tool: 'read_file', + input: { path: '/test.txt' }, + }, + }; + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should handle tool_result message type', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'tool_result', + part: { + id: 'result_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'tool-result', + toolCallID: 'tool_1', + output: 'File contents here', + }, + }; + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should handle step_finish message type', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'step_finish', + part: { + id: 'step_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'step-finish', + reason: 'stop', + }, + }; + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledWith(message); + }); + }); + + describe('chunked data across multiple feed calls', () => { + it('should buffer incomplete JSON and parse when complete', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Complete message', + }, + }; + const json = JSON.stringify(message); + const chunk1 = json.substring(0, 20); + const chunk2 = json.substring(20) + '\n'; + + // Act + parser.feed(chunk1); + expect(messageHandler).not.toHaveBeenCalled(); + + parser.feed(chunk2); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should handle message split across three chunks', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'A longer message to split into parts', + }, + }; + const json = JSON.stringify(message); + const chunk1 = json.substring(0, 15); + const chunk2 = json.substring(15, 40); + const chunk3 = json.substring(40) + '\n'; + + // Act + parser.feed(chunk1); + parser.feed(chunk2); + expect(messageHandler).not.toHaveBeenCalled(); + + parser.feed(chunk3); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should handle complete message followed by partial in same feed', () => { + // Arrange + const message1: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'First', + }, + }; + const message2: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_2', + sessionID: 'session_1', + messageID: 'msg_2', + type: 'text', + text: 'Second', + }, + }; + const json2 = JSON.stringify(message2); + + // Act + parser.feed(JSON.stringify(message1) + '\n' + json2.substring(0, 10)); + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message1); + + parser.feed(json2.substring(10) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(2); + expect(messageHandler).toHaveBeenNthCalledWith(2, message2); + }); + }); + + describe('incomplete JSON handling', () => { + it('should keep incomplete JSON in buffer until newline', () => { + // Arrange + const incomplete = '{"type":"text","part":{"id":"1","text":"no newline"}'; + + // Act + parser.feed(incomplete); + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('should flush incomplete buffer when flush() is called', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Flushed message', + }, + }; + + // Act + parser.feed(JSON.stringify(message)); + expect(messageHandler).not.toHaveBeenCalled(); + + parser.flush(); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message); + }); + + it('should skip empty lines', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Message', + }, + }; + + // Act + parser.feed('\n\n' + JSON.stringify(message) + '\n\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + }); + + it('should skip whitespace-only lines', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Message', + }, + }; + + // Act + parser.feed(' \n' + JSON.stringify(message) + '\n \t \n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('terminal decoration filtering', () => { + it('should skip lines starting with box-drawing characters', () => { + // Arrange + const boxDrawingLines = [ + '│ Some content', + '┌────────────', + '┐', + '└────────────', + '┘', + '├──────────', + '┤', + '┬', + '┴', + '┼', + '─────────', + '◆ Option 1', + '● Selected', + '○ Unselected', + '◇ Diamond', + ]; + + // Act + for (const line of boxDrawingLines) { + parser.feed(line + '\n'); + } + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('should skip ANSI escape sequences', () => { + // Arrange + const ansiLines = [ + '\x1b[31mRed text\x1b[0m', + '\x1b[1;32mBold green\x1b[0m', + '\x1b[2m dimmed text \x1b[22m', + ]; + + // Act + for (const line of ansiLines) { + parser.feed(line + '\n'); + } + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + }); + + it('should skip control characters at start of line', () => { + // Arrange + const controlLines = [ + '\x00null char', + '\x07bell', + '\x1funit separator', + '\x7fdelete', + ]; + + // Act + for (const line of controlLines) { + parser.feed(line + '\n'); + } + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + }); + + it('should skip lines not starting with {', () => { + // Arrange + const nonJsonLines = [ + 'Some plain text', + '123 a number', + '[array start]', + 'Status: running', + ]; + + // Act + for (const line of nonJsonLines) { + parser.feed(line + '\n'); + } + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('should parse valid JSON after skipping decorations', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Valid', + }, + }; + + // Act + parser.feed('│ Header\n'); + parser.feed(JSON.stringify(message) + '\n'); + parser.feed('└─────\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message); + }); + }); + + describe('buffer overflow protection', () => { + it('should emit error and truncate buffer when exceeding max size', () => { + // Arrange + const maxBufferSize = 10 * 1024 * 1024; // 10MB + const largeChunk = 'x'.repeat(maxBufferSize + 100); + + // Act + parser.feed(largeChunk); + + // Assert + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Stream buffer size exceeded maximum limit', + }) + ); + }); + + it('should keep parsing continuity after buffer truncation and reset', () => { + // Arrange - Feed large data to trigger truncation + const maxBufferSize = 10 * 1024 * 1024; + const largeChunk = 'x'.repeat(maxBufferSize + 100); + + // Act - First trigger overflow + parser.feed(largeChunk); + + // Reset parser and handlers to verify continued operation + parser.reset(); // Clear corrupted buffer + messageHandler.mockClear(); + errorHandler.mockClear(); + + // Feed valid message after overflow + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'After overflow', + }, + }; + parser.feed(JSON.stringify(message) + '\n'); + + // Assert - Parser should still work after reset + expect(messageHandler).toHaveBeenCalledWith(message); + }); + }); + + describe('NDJSON format parsing', () => { + it('should parse newline-delimited JSON stream', () => { + // Arrange + const messages: OpenCodeMessage[] = [ + { + type: 'step_start', + part: { id: 's1', sessionID: 'sess', messageID: 'm1', type: 'step-start' }, + }, + { + type: 'text', + part: { id: 't1', sessionID: 'sess', messageID: 'm1', type: 'text', text: 'Hello' }, + }, + { + type: 'step_finish', + part: { id: 's1', sessionID: 'sess', messageID: 'm1', type: 'step-finish', reason: 'stop' }, + }, + ]; + + const ndjson = messages.map((m) => JSON.stringify(m)).join('\n') + '\n'; + + // Act + parser.feed(ndjson); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(3); + messages.forEach((msg, i) => { + expect(messageHandler).toHaveBeenNthCalledWith(i + 1, msg); + }); + }); + + it('should handle Windows line endings (CRLF)', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Windows', + }, + }; + // Note: \r\n ends up with \r as part of the JSON which fails parsing + // The parser only splits on \n, so \r becomes part of the line + // This is actually correct behavior - the CLI should output \n only + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('error events for malformed JSON', () => { + it('should emit error for invalid JSON starting with {', () => { + // Arrange + const malformedJson = '{invalid json here}\n'; + + // Act + parser.feed(malformedJson); + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Failed to parse JSON'), + }) + ); + }); + + it('should emit error for truncated JSON', () => { + // Arrange + const truncatedJson = '{"type":"text","part":{"text":"incomplete\n'; + + // Act + parser.feed(truncatedJson); + + // Assert + expect(errorHandler).toHaveBeenCalledTimes(1); + }); + + it('should continue parsing after error', () => { + // Arrange + const malformed = '{bad}\n'; + const validMessage: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Valid', + }, + }; + + // Act + parser.feed(malformed); + parser.feed(JSON.stringify(validMessage) + '\n'); + + // Assert + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(validMessage); + }); + + it('should not emit error for non-JSON lines not starting with {', () => { + // Arrange + const nonJsonLines = 'Status: OK\nProgress: 50%\n'; + + // Act + parser.feed(nonJsonLines); + + // Assert + expect(errorHandler).not.toHaveBeenCalled(); + }); + }); + + describe('reset()', () => { + it('should clear the buffer', () => { + // Arrange + parser.feed('{"partial": "json"'); + + // Act + parser.reset(); + parser.feed('}\n'); // This should not parse without the beginning + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + }); + + it('should allow fresh parsing after reset', () => { + // Arrange + parser.feed('old partial data'); + parser.reset(); + + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Fresh', + }, + }; + + // Act + parser.feed(JSON.stringify(message) + '\n'); + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + expect(messageHandler).toHaveBeenCalledWith(message); + }); + }); + + describe('flush()', () => { + it('should do nothing if buffer is empty', () => { + // Act + parser.flush(); + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('should do nothing if buffer contains only whitespace', () => { + // Arrange + parser.feed(' \t '); + + // Act + parser.flush(); + + // Assert + expect(messageHandler).not.toHaveBeenCalled(); + }); + + it('should clear buffer after flushing', () => { + // Arrange + const message: OpenCodeMessage = { + type: 'text', + part: { + id: 'msg_1', + sessionID: 'session_1', + messageID: 'msg_1', + type: 'text', + text: 'Message', + }, + }; + parser.feed(JSON.stringify(message)); + + // Act + parser.flush(); + parser.flush(); // Second flush should do nothing + + // Assert + expect(messageHandler).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts new file mode 100644 index 000000000..372124cd7 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts @@ -0,0 +1,437 @@ +import { describe, it, expect } from 'vitest'; +import { cn } from '../../../src/renderer/lib/utils'; + +describe('utils.ts', () => { + describe('cn() - class name merging', () => { + describe('basic usage', () => { + it('should return single class unchanged', () => { + // Act + const result = cn('text-red-500'); + + // Assert + expect(result).toBe('text-red-500'); + }); + + it('should merge multiple classes', () => { + // Act + const result = cn('text-red-500', 'bg-white'); + + // Assert + expect(result).toBe('text-red-500 bg-white'); + }); + + it('should handle empty string inputs', () => { + // Act + const result = cn('', 'text-red-500', ''); + + // Assert + expect(result).toBe('text-red-500'); + }); + + it('should handle no arguments', () => { + // Act + const result = cn(); + + // Assert + expect(result).toBe(''); + }); + + it('should handle single empty string', () => { + // Act + const result = cn(''); + + // Assert + expect(result).toBe(''); + }); + }); + + describe('conditional classes with clsx', () => { + it('should include class when condition is true', () => { + // Arrange + const isActive = true; + + // Act + const result = cn('base', isActive && 'active'); + + // Assert + expect(result).toBe('base active'); + }); + + it('should exclude class when condition is false', () => { + // Arrange + const isActive = false; + + // Act + const result = cn('base', isActive && 'active'); + + // Assert + expect(result).toBe('base'); + }); + + it('should handle object syntax for conditionals', () => { + // Arrange + const isActive = true; + const isDisabled = false; + + // Act + const result = cn('base', { + active: isActive, + disabled: isDisabled, + }); + + // Assert + expect(result).toBe('base active'); + }); + + it('should handle array of classes', () => { + // Act + const result = cn(['text-red-500', 'bg-white']); + + // Assert + expect(result).toBe('text-red-500 bg-white'); + }); + + it('should handle nested arrays', () => { + // Act + const result = cn(['base', ['nested1', 'nested2']]); + + // Assert + expect(result).toBe('base nested1 nested2'); + }); + + it('should handle null and undefined values', () => { + // Act + const result = cn('base', null, undefined, 'end'); + + // Assert + expect(result).toBe('base end'); + }); + + it('should handle false and 0 values', () => { + // Act + const result = cn('base', false, 0, 'end'); + + // Assert + expect(result).toBe('base end'); + }); + }); + + describe('Tailwind conflict resolution', () => { + it('should resolve conflicting padding classes (later wins)', () => { + // Act + const result = cn('p-4', 'p-8'); + + // Assert + expect(result).toBe('p-8'); + }); + + it('should resolve conflicting margin classes', () => { + // Act + const result = cn('m-2', 'm-4'); + + // Assert + expect(result).toBe('m-4'); + }); + + it('should resolve conflicting text color classes', () => { + // Act + const result = cn('text-red-500', 'text-blue-500'); + + // Assert + expect(result).toBe('text-blue-500'); + }); + + it('should resolve conflicting background color classes', () => { + // Act + const result = cn('bg-white', 'bg-black'); + + // Assert + expect(result).toBe('bg-black'); + }); + + it('should not merge non-conflicting classes', () => { + // Act + const result = cn('text-red-500', 'bg-white', 'p-4'); + + // Assert + expect(result).toBe('text-red-500 bg-white p-4'); + }); + + it('should resolve conflicting font size classes', () => { + // Act + const result = cn('text-sm', 'text-lg'); + + // Assert + expect(result).toBe('text-lg'); + }); + + it('should resolve conflicting font weight classes', () => { + // Act + const result = cn('font-normal', 'font-bold'); + + // Assert + expect(result).toBe('font-bold'); + }); + + it('should resolve conflicting display classes', () => { + // Act + const result = cn('block', 'flex'); + + // Assert + expect(result).toBe('flex'); + }); + + it('should resolve conflicting width classes', () => { + // Act + const result = cn('w-full', 'w-1/2'); + + // Assert + expect(result).toBe('w-1/2'); + }); + + it('should resolve conflicting height classes', () => { + // Act + const result = cn('h-10', 'h-20'); + + // Assert + expect(result).toBe('h-20'); + }); + + it('should handle directional padding without conflict', () => { + // Act + const result = cn('px-4', 'py-2'); + + // Assert + expect(result).toBe('px-4 py-2'); + }); + + it('should resolve px vs px conflicts', () => { + // Act + const result = cn('px-4', 'px-8'); + + // Assert + expect(result).toBe('px-8'); + }); + + it('should not confuse px with p', () => { + // Act + const result = cn('p-4', 'px-8'); + + // Assert + expect(result).toContain('p-4'); + expect(result).toContain('px-8'); + }); + + it('should resolve conflicting rounded classes', () => { + // Act + const result = cn('rounded', 'rounded-lg'); + + // Assert + expect(result).toBe('rounded-lg'); + }); + + it('should resolve conflicting border classes', () => { + // Act + const result = cn('border', 'border-2'); + + // Assert + expect(result).toBe('border-2'); + }); + + it('should resolve conflicting z-index classes', () => { + // Act + const result = cn('z-10', 'z-50'); + + // Assert + expect(result).toBe('z-50'); + }); + }); + + describe('responsive and state variants', () => { + it('should handle responsive prefixes', () => { + // Act + const result = cn('text-sm', 'md:text-base', 'lg:text-lg'); + + // Assert + expect(result).toBe('text-sm md:text-base lg:text-lg'); + }); + + it('should resolve conflicts within same breakpoint', () => { + // Act + const result = cn('md:text-sm', 'md:text-lg'); + + // Assert + expect(result).toBe('md:text-lg'); + }); + + it('should handle hover states', () => { + // Act + const result = cn('bg-white', 'hover:bg-gray-100'); + + // Assert + expect(result).toBe('bg-white hover:bg-gray-100'); + }); + + it('should resolve hover state conflicts', () => { + // Act + const result = cn('hover:bg-gray-100', 'hover:bg-gray-200'); + + // Assert + expect(result).toBe('hover:bg-gray-200'); + }); + + it('should handle focus states', () => { + // Act + const result = cn('outline-none', 'focus:outline-2'); + + // Assert + expect(result).toBe('outline-none focus:outline-2'); + }); + + it('should handle dark mode', () => { + // Act + const result = cn('bg-white', 'dark:bg-gray-900'); + + // Assert + expect(result).toBe('bg-white dark:bg-gray-900'); + }); + }); + + describe('complex real-world usage', () => { + it('should handle button variant pattern', () => { + // Arrange + const baseClasses = 'px-4 py-2 rounded font-medium'; + const variantClasses = 'bg-blue-500 text-white hover:bg-blue-600'; + const sizeOverride = 'px-6 py-3'; + + // Act + const result = cn(baseClasses, variantClasses, sizeOverride); + + // Assert + expect(result).toContain('px-6'); + expect(result).toContain('py-3'); + expect(result).toContain('rounded'); + expect(result).toContain('font-medium'); + expect(result).toContain('bg-blue-500'); + expect(result).not.toContain('px-4'); + expect(result).not.toContain('py-2'); + }); + + it('should handle conditional disabled state', () => { + // Arrange + const isDisabled = true; + const baseClasses = 'bg-blue-500 cursor-pointer'; + const disabledClasses = isDisabled && 'bg-gray-300 cursor-not-allowed'; + + // Act + const result = cn(baseClasses, disabledClasses); + + // Assert + expect(result).toContain('bg-gray-300'); + expect(result).toContain('cursor-not-allowed'); + expect(result).not.toContain('bg-blue-500'); + expect(result).not.toContain('cursor-pointer'); + }); + + it('should handle component prop className override', () => { + // Arrange - simulating component with default + user override + const defaultClasses = 'text-sm text-gray-500'; + const userClassName = 'text-lg text-blue-500'; + + // Act + const result = cn(defaultClasses, userClassName); + + // Assert + expect(result).toBe('text-lg text-blue-500'); + }); + + it('should handle mixed array and string inputs', () => { + // Arrange + const conditionalClasses = ['rounded-lg', 'shadow-md']; + const isLarge = true; + + // Act + const result = cn('base', conditionalClasses, isLarge && 'w-full'); + + // Assert + expect(result).toBe('base rounded-lg shadow-md w-full'); + }); + + it('should handle arbitrary values', () => { + // Act + const result = cn('w-[200px]', 'h-[100px]'); + + // Assert + expect(result).toBe('w-[200px] h-[100px]'); + }); + + it('should resolve arbitrary value conflicts', () => { + // Act + const result = cn('w-[200px]', 'w-[300px]'); + + // Assert + expect(result).toBe('w-[300px]'); + }); + }); + + describe('edge cases', () => { + it('should handle classes with numbers', () => { + // Act + const result = cn('grid-cols-3', 'gap-4'); + + // Assert + expect(result).toBe('grid-cols-3 gap-4'); + }); + + it('should handle negative values', () => { + // Act + const result = cn('-mt-4', '-ml-2'); + + // Assert + expect(result).toBe('-mt-4 -ml-2'); + }); + + it('should handle important modifier', () => { + // Act + const result = cn('!text-red-500', '!bg-white'); + + // Assert + expect(result).toBe('!text-red-500 !bg-white'); + }); + + it('should handle whitespace in class strings', () => { + // Act + const result = cn(' text-red-500 ', ' bg-white '); + + // Assert + expect(result).toBe('text-red-500 bg-white'); + }); + + it('should handle multiple spaces between classes', () => { + // Act + const result = cn('text-red-500 bg-white'); + + // Assert + expect(result).toBe('text-red-500 bg-white'); + }); + + it('should handle deeply nested conditionals', () => { + // Arrange + const a = true; + const b = false; + const c = true; + + // Act + const result = cn( + 'base', + a && 'a-true', + b && 'b-true', + c && ['c-true', b && 'cb-true'] + ); + + // Assert + expect(result).toBe('base a-true c-true'); + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/setup.ts b/openwork-memos-integration/apps/desktop/__tests__/setup.ts new file mode 100644 index 000000000..5a0fde106 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/setup.ts @@ -0,0 +1,13 @@ +/** + * Vitest setup file for tests + * Configures testing-library matchers and global test utilities + */ + +import '@testing-library/jest-dom/vitest'; + +// Extend global types for test utilities +declare global { + // Add any global test utilities here if needed +} + +export {}; diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts new file mode 100644 index 000000000..1399ee222 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts @@ -0,0 +1,1946 @@ +/** + * Unit tests for IPC handlers + * + * Tests the registration and invocation of IPC handlers for: + * - Task operations (start, cancel, interrupt, get, list, delete, clear) + * - API key management (get, set, validate, delete) + * - Settings (debug mode, app settings, model selection) + * - Onboarding + * - Permission responses + * - Session management + * + * NOTE: This is a UNIT test, not an integration test. + * All dependent modules (taskHistory, secureStorage, appSettings, task-manager, adapter) + * are mocked to test handler logic in isolation. This follows the principle that + * unit tests should test a single unit with all dependencies mocked. + * + * For true integration testing, see the integration tests that use real + * implementations with temp directories. + * + * @module __tests__/unit/main/ipc/handlers.unit.test + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// Mock electron modules before importing handlers +vi.mock('electron', () => { + const mockHandlers = new Map(); + const mockListeners = new Map>(); + + return { + ipcMain: { + handle: vi.fn((channel: string, handler: Function) => { + mockHandlers.set(channel, handler); + }), + on: vi.fn((channel: string, listener: Function) => { + if (!mockListeners.has(channel)) { + mockListeners.set(channel, new Set()); + } + mockListeners.get(channel)!.add(listener); + }), + removeHandler: vi.fn((channel: string) => { + mockHandlers.delete(channel); + }), + removeAllListeners: vi.fn((channel?: string) => { + if (channel) { + mockListeners.delete(channel); + } else { + mockListeners.clear(); + } + }), + // Helper to get registered handler for testing + _getHandler: (channel: string) => mockHandlers.get(channel), + _getHandlers: () => mockHandlers, + _clear: () => { + mockHandlers.clear(); + mockListeners.clear(); + }, + }, + BrowserWindow: { + fromWebContents: vi.fn(() => ({ + id: 1, + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn(), + isDestroyed: vi.fn(() => false), + }, + })), + getFocusedWindow: vi.fn(() => ({ + id: 1, + isDestroyed: vi.fn(() => false), + })), + getAllWindows: vi.fn(() => [{ id: 1, webContents: { send: vi.fn() } }]), + }, + shell: { + openExternal: vi.fn(), + }, + app: { + isPackaged: false, + getPath: vi.fn(() => '/tmp/test-app'), + }, + }; +}); + +// Mock opencode adapter +vi.mock('@main/opencode/adapter', () => ({ + isOpenCodeCliInstalled: vi.fn(() => Promise.resolve(true)), + getOpenCodeCliVersion: vi.fn(() => Promise.resolve('1.0.0')), +})); + +// Mock task manager +const mockTaskManager = { + startTask: vi.fn(), + cancelTask: vi.fn(), + interruptTask: vi.fn(), + sendResponse: vi.fn(), + hasActiveTask: vi.fn(() => false), + getActiveTaskId: vi.fn(() => null), + getSessionId: vi.fn(() => null), + isTaskQueued: vi.fn(() => false), + cancelQueuedTask: vi.fn(), +}; + +vi.mock('@main/opencode/task-manager', () => ({ + getTaskManager: vi.fn(() => mockTaskManager), + disposeTaskManager: vi.fn(), +})); + +// Mock task history +const mockTasks: Array<{ + id: string; + prompt: string; + status: string; + messages: unknown[]; + createdAt: string; +}> = []; + +vi.mock('@main/store/taskHistory', () => ({ + getTasks: vi.fn(() => mockTasks), + getTask: vi.fn((taskId: string) => mockTasks.find((t) => t.id === taskId)), + saveTask: vi.fn((task: unknown) => { + const t = task as { id: string }; + const existing = mockTasks.findIndex((x) => x.id === t.id); + if (existing >= 0) { + mockTasks[existing] = task as (typeof mockTasks)[0]; + } else { + mockTasks.push(task as (typeof mockTasks)[0]); + } + }), + updateTaskStatus: vi.fn(), + updateTaskSessionId: vi.fn(), + updateTaskSummary: vi.fn(), + addTaskMessage: vi.fn(), + deleteTask: vi.fn((taskId: string) => { + const idx = mockTasks.findIndex((t) => t.id === taskId); + if (idx >= 0) mockTasks.splice(idx, 1); + }), + clearHistory: vi.fn(() => { + mockTasks.length = 0; + }), +})); + +// Mock secure storage +let mockApiKeys: Record = {}; +let mockStoredCredentials: Array<{ account: string; password: string }> = []; + +vi.mock('@main/store/secureStorage', () => ({ + storeApiKey: vi.fn((provider: string, key: string) => { + mockApiKeys[provider] = key; + mockStoredCredentials.push({ account: `apiKey:${provider}`, password: key }); + }), + getApiKey: vi.fn((provider: string) => mockApiKeys[provider] || null), + deleteApiKey: vi.fn((provider: string) => { + delete mockApiKeys[provider]; + mockStoredCredentials = mockStoredCredentials.filter( + (c) => c.account !== `apiKey:${provider}` + ); + }), + getAllApiKeys: vi.fn(() => + Promise.resolve({ + anthropic: mockApiKeys['anthropic'] || null, + openai: mockApiKeys['openai'] || null, + google: mockApiKeys['google'] || null, + xai: mockApiKeys['xai'] || null, + custom: mockApiKeys['custom'] || null, + }) + ), + hasAnyApiKey: vi.fn(() => + Promise.resolve(Object.values(mockApiKeys).some((k) => k !== null)) + ), + listStoredCredentials: vi.fn(() => mockStoredCredentials), +})); + +// Mock app settings +let mockDebugMode = false; +let mockOnboardingComplete = false; +let mockSelectedModel: { provider: string; model: string } | null = null; + +vi.mock('@main/store/appSettings', () => ({ + getDebugMode: vi.fn(() => mockDebugMode), + setDebugMode: vi.fn((enabled: boolean) => { + mockDebugMode = enabled; + }), + getAppSettings: vi.fn(() => ({ + debugMode: mockDebugMode, + onboardingComplete: mockOnboardingComplete, + selectedModel: mockSelectedModel, + })), + getOnboardingComplete: vi.fn(() => mockOnboardingComplete), + setOnboardingComplete: vi.fn((complete: boolean) => { + mockOnboardingComplete = complete; + }), + getSelectedModel: vi.fn(() => mockSelectedModel), + setSelectedModel: vi.fn((model: { provider: string; model: string }) => { + mockSelectedModel = model; + }), +})); + +// Mock provider settings +vi.mock('@main/store/providerSettings', () => ({ + getProviderSettings: vi.fn(() => ({ + activeProviderId: 'anthropic', + connectedProviders: { + anthropic: { + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + }, + }, + debugMode: false, + })), + saveProviderSettings: vi.fn(), + getActiveProvider: vi.fn(() => ({ + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + })), + setActiveProvider: vi.fn(), + getConnectedProvider: vi.fn(() => ({ + providerId: 'anthropic', + connectionStatus: 'connected', + selectedModelId: 'claude-3-5-sonnet-20241022', + credentials: { type: 'api-key', apiKey: 'test-key' }, + })), + saveConnectedProvider: vi.fn(), + removeConnectedProvider: vi.fn(), + getActiveProviderModel: vi.fn(() => ({ provider: 'anthropic', model: 'anthropic/claude-3-5-sonnet-20241022' })), + getConnectedProviderIds: vi.fn(() => ['anthropic']), + setProviderDebugMode: vi.fn(), + getProviderDebugMode: vi.fn(() => false), + hasReadyProvider: vi.fn(() => true), +})); + +// Mock config +vi.mock('@main/config', () => ({ + getDesktopConfig: vi.fn(() => ({})), +})); + +// Mock permission API +let mockPendingPermissions = new Map(); + +vi.mock('@main/permission-api', () => ({ + startPermissionApiServer: vi.fn(), + startQuestionApiServer: vi.fn(), + initPermissionApi: vi.fn(), + resolvePermission: vi.fn((requestId: string, allowed: boolean) => { + const pending = mockPendingPermissions.get(requestId); + if (pending) { + pending.resolve(allowed); + mockPendingPermissions.delete(requestId); + return true; + } + return false; + }), + resolveQuestion: vi.fn(() => true), + isFilePermissionRequest: vi.fn((requestId: string) => requestId.startsWith('filereq_')), + isQuestionRequest: vi.fn((requestId: string) => requestId.startsWith('question_')), + QUESTION_API_PORT: 9227, +})); + +// Import after mocks are set up +import { registerIPCHandlers } from '@main/ipc/handlers'; +import { ipcMain, BrowserWindow, shell } from 'electron'; + +// Type the mocked ipcMain with helpers +type MockedIpcMain = typeof ipcMain & { + _getHandler: (channel: string) => Function | undefined; + _getHandlers: () => Map; + _clear: () => void; +}; + +const mockedIpcMain = ipcMain as MockedIpcMain; + +/** + * Helper to invoke a registered handler + */ +async function invokeHandler(channel: string, ...args: unknown[]): Promise { + const handler = mockedIpcMain._getHandler(channel); + if (!handler) { + throw new Error(`No handler registered for channel: ${channel}`); + } + + // Create mock event + const mockEvent = { + sender: { + send: vi.fn(), + isDestroyed: vi.fn(() => false), + }, + }; + + return handler(mockEvent, ...args); +} + +describe('IPC Handlers Integration', () => { + beforeEach(() => { + // Reset all mocks and state + vi.clearAllMocks(); + mockedIpcMain._clear(); + mockTasks.length = 0; + mockApiKeys = {}; + mockStoredCredentials = []; + mockDebugMode = false; + mockOnboardingComplete = false; + mockSelectedModel = null; + mockPendingPermissions.clear(); + + // Reset task manager mocks + mockTaskManager.startTask.mockReset(); + mockTaskManager.cancelTask.mockReset(); + mockTaskManager.interruptTask.mockReset(); + mockTaskManager.sendResponse.mockReset(); + mockTaskManager.hasActiveTask.mockReturnValue(false); + mockTaskManager.getActiveTaskId.mockReturnValue(null); + mockTaskManager.getSessionId.mockReturnValue(null); + mockTaskManager.isTaskQueued.mockReturnValue(false); + mockTaskManager.cancelQueuedTask.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('registerIPCHandlers', () => { + it('should register all expected IPC handlers', () => { + // Arrange & Act + registerIPCHandlers(); + + // Assert + const handlers = mockedIpcMain._getHandlers(); + + // Task handlers + expect(handlers.has('task:start')).toBe(true); + expect(handlers.has('task:cancel')).toBe(true); + expect(handlers.has('task:interrupt')).toBe(true); + expect(handlers.has('task:get')).toBe(true); + expect(handlers.has('task:list')).toBe(true); + expect(handlers.has('task:delete')).toBe(true); + expect(handlers.has('task:clear-history')).toBe(true); + + // Permission handler + expect(handlers.has('permission:respond')).toBe(true); + + // Session handler + expect(handlers.has('session:resume')).toBe(true); + + // Settings handlers + expect(handlers.has('settings:api-keys')).toBe(true); + expect(handlers.has('settings:add-api-key')).toBe(true); + expect(handlers.has('settings:remove-api-key')).toBe(true); + expect(handlers.has('settings:debug-mode')).toBe(true); + expect(handlers.has('settings:set-debug-mode')).toBe(true); + expect(handlers.has('settings:app-settings')).toBe(true); + + // API key handlers + expect(handlers.has('api-key:exists')).toBe(true); + expect(handlers.has('api-key:set')).toBe(true); + expect(handlers.has('api-key:get')).toBe(true); + expect(handlers.has('api-key:validate')).toBe(true); + expect(handlers.has('api-key:validate-provider')).toBe(true); + expect(handlers.has('api-key:clear')).toBe(true); + + // Multi-provider API key handlers + expect(handlers.has('api-keys:all')).toBe(true); + expect(handlers.has('api-keys:has-any')).toBe(true); + + // OpenCode handlers + expect(handlers.has('opencode:check')).toBe(true); + expect(handlers.has('opencode:version')).toBe(true); + + // Model handlers + expect(handlers.has('model:get')).toBe(true); + expect(handlers.has('model:set')).toBe(true); + + // Onboarding handlers + expect(handlers.has('onboarding:complete')).toBe(true); + expect(handlers.has('onboarding:set-complete')).toBe(true); + + // Shell handler + expect(handlers.has('shell:open-external')).toBe(true); + + // Log handler + expect(handlers.has('log:event')).toBe(true); + }); + }); + + describe('API Key Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('api-key:exists should return false when no key is stored', async () => { + // Arrange - no keys stored + + // Act + const result = await invokeHandler('api-key:exists'); + + // Assert + expect(result).toBe(false); + }); + + it('api-key:set should store the API key', async () => { + // Arrange + const testKey = 'sk-test-12345678-abcdef'; + + // Act + await invokeHandler('api-key:set', testKey); + mockApiKeys['anthropic'] = testKey; // Simulate storage + const exists = await invokeHandler('api-key:exists'); + + // Assert + expect(exists).toBe(true); + }); + + it('api-key:get should retrieve the stored API key', async () => { + // Arrange + const testKey = 'sk-test-retrieve-key'; + mockApiKeys['anthropic'] = testKey; + + // Act + const result = await invokeHandler('api-key:get'); + + // Assert + expect(result).toBe(testKey); + }); + + it('api-key:clear should remove the stored API key', async () => { + // Arrange + mockApiKeys['anthropic'] = 'sk-test-to-delete'; + + // Act + await invokeHandler('api-key:clear'); + + // Assert - check deleteApiKey was called + const { deleteApiKey } = await import('@main/store/secureStorage'); + expect(deleteApiKey).toHaveBeenCalledWith('anthropic'); + }); + + it('api-key:set should reject empty keys', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('api-key:set', '')).rejects.toThrow(); + await expect(invokeHandler('api-key:set', ' ')).rejects.toThrow(); + }); + + it('api-key:set should reject keys exceeding max length', async () => { + // Arrange + const longKey = 'x'.repeat(300); + + // Act & Assert + await expect(invokeHandler('api-key:set', longKey)).rejects.toThrow('exceeds maximum length'); + }); + }); + + describe('Settings Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('settings:debug-mode should return current debug mode', async () => { + // Arrange + mockDebugMode = true; + + // Act + const result = await invokeHandler('settings:debug-mode'); + + // Assert + expect(result).toBe(true); + }); + + it('settings:set-debug-mode should update debug mode', async () => { + // Arrange + mockDebugMode = false; + + // Act + await invokeHandler('settings:set-debug-mode', true); + + // Assert + const { setDebugMode } = await import('@main/store/appSettings'); + expect(setDebugMode).toHaveBeenCalledWith(true); + }); + + it('settings:set-debug-mode should reject non-boolean values', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('settings:set-debug-mode', 'true')).rejects.toThrow( + 'Invalid debug mode flag' + ); + await expect(invokeHandler('settings:set-debug-mode', 1)).rejects.toThrow( + 'Invalid debug mode flag' + ); + }); + + it('settings:app-settings should return all app settings', async () => { + // Arrange + mockDebugMode = true; + mockOnboardingComplete = true; + mockSelectedModel = { provider: 'anthropic', model: 'claude-3-opus' }; + + // Act + const result = await invokeHandler('settings:app-settings'); + + // Assert + expect(result).toEqual({ + debugMode: true, + onboardingComplete: true, + selectedModel: { provider: 'anthropic', model: 'claude-3-opus' }, + }); + }); + + it('settings:api-keys should return list of stored API keys', async () => { + // Arrange + mockStoredCredentials = [ + { account: 'apiKey:anthropic', password: 'sk-ant-12345678' }, + { account: 'apiKey:openai', password: 'sk-openai-abcdefgh' }, + ]; + + // Act + const result = await invokeHandler('settings:api-keys'); + + // Assert + expect(result).toHaveLength(2); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: 'anthropic', + keyPrefix: 'sk-ant-1...', + }), + expect.objectContaining({ + provider: 'openai', + keyPrefix: 'sk-opena...', + }), + ]) + ); + }); + + it('settings:add-api-key should store API key for valid provider', async () => { + // Arrange + const provider = 'anthropic'; + const key = 'sk-ant-new-key-12345'; + + // Act + const result = await invokeHandler('settings:add-api-key', provider, key); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + provider: 'anthropic', + keyPrefix: 'sk-ant-n...', + isActive: true, + }) + ); + }); + + it('settings:add-api-key should reject unsupported providers', async () => { + // Arrange & Act & Assert + await expect( + invokeHandler('settings:add-api-key', 'unsupported-provider', 'sk-test') + ).rejects.toThrow('Unsupported API key provider'); + }); + + it('settings:remove-api-key should delete the API key', async () => { + // Arrange + mockApiKeys['openai'] = 'sk-openai-test'; + + // Act + await invokeHandler('settings:remove-api-key', 'local-openai'); + + // Assert + const { deleteApiKey } = await import('@main/store/secureStorage'); + expect(deleteApiKey).toHaveBeenCalledWith('openai'); + }); + }); + + describe('Task Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('task:start should create and start a new task', async () => { + // Arrange + const config = { prompt: 'Test task prompt' }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_123', + prompt: 'Test task prompt', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('task:start', config); + + // Assert + expect(mockTaskManager.startTask).toHaveBeenCalledWith( + expect.stringMatching(/^task_/), + expect.objectContaining({ prompt: 'Test task prompt' }), + expect.any(Object) + ); + expect(result).toEqual( + expect.objectContaining({ + prompt: 'Test task prompt', + status: 'running', + }) + ); + }); + + it('task:start should validate task config', async () => { + // Arrange - empty prompt + + // Act & Assert + await expect(invokeHandler('task:start', { prompt: '' })).rejects.toThrow(); + await expect(invokeHandler('task:start', { prompt: ' ' })).rejects.toThrow(); + }); + + it('task:cancel should cancel a running task', async () => { + // Arrange + const taskId = 'task_to_cancel'; + mockTaskManager.hasActiveTask.mockReturnValue(true); + + // Act + await invokeHandler('task:cancel', taskId); + + // Assert + expect(mockTaskManager.cancelTask).toHaveBeenCalledWith(taskId); + }); + + it('task:cancel should cancel a queued task', async () => { + // Arrange + const taskId = 'task_queued'; + mockTaskManager.isTaskQueued.mockReturnValue(true); + + // Act + await invokeHandler('task:cancel', taskId); + + // Assert + expect(mockTaskManager.cancelQueuedTask).toHaveBeenCalledWith(taskId); + }); + + it('task:cancel should do nothing for non-existent task', async () => { + // Arrange + const taskId = 'task_nonexistent'; + mockTaskManager.isTaskQueued.mockReturnValue(false); + mockTaskManager.hasActiveTask.mockReturnValue(false); + + // Act + await invokeHandler('task:cancel', taskId); + + // Assert + expect(mockTaskManager.cancelTask).not.toHaveBeenCalled(); + expect(mockTaskManager.cancelQueuedTask).not.toHaveBeenCalled(); + }); + + it('task:interrupt should interrupt a running task', async () => { + // Arrange + const taskId = 'task_to_interrupt'; + mockTaskManager.hasActiveTask.mockReturnValue(true); + + // Act + await invokeHandler('task:interrupt', taskId); + + // Assert + expect(mockTaskManager.interruptTask).toHaveBeenCalledWith(taskId); + }); + + it('task:get should return task from history', async () => { + // Arrange + const taskId = 'task_existing'; + mockTasks.push({ + id: taskId, + prompt: 'Existing task', + status: 'completed', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('task:get', taskId); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + id: taskId, + prompt: 'Existing task', + status: 'completed', + }) + ); + }); + + it('task:get should return null for non-existent task', async () => { + // Arrange - no tasks + + // Act + const result = await invokeHandler('task:get', 'task_nonexistent'); + + // Assert + expect(result).toBeNull(); + }); + + it('task:list should return all tasks from history', async () => { + // Arrange + mockTasks.push( + { + id: 'task_1', + prompt: 'Task 1', + status: 'completed', + messages: [], + createdAt: new Date().toISOString(), + }, + { + id: 'task_2', + prompt: 'Task 2', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + } + ); + + // Act + const result = await invokeHandler('task:list'); + + // Assert + expect(result).toHaveLength(2); + }); + + it('task:delete should remove task from history', async () => { + // Arrange + const taskId = 'task_to_delete'; + mockTasks.push({ + id: taskId, + prompt: 'Task to delete', + status: 'completed', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('task:delete', taskId); + + // Assert + const { deleteTask } = await import('@main/store/taskHistory'); + expect(deleteTask).toHaveBeenCalledWith(taskId); + }); + + it('task:clear-history should clear all tasks', async () => { + // Arrange + mockTasks.push( + { + id: 'task_1', + prompt: 'Task 1', + status: 'completed', + messages: [], + createdAt: new Date().toISOString(), + }, + { + id: 'task_2', + prompt: 'Task 2', + status: 'completed', + messages: [], + createdAt: new Date().toISOString(), + } + ); + + // Act + await invokeHandler('task:clear-history'); + + // Assert + const { clearHistory } = await import('@main/store/taskHistory'); + expect(clearHistory).toHaveBeenCalled(); + }); + }); + + describe('Onboarding Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('onboarding:complete should return false when not completed', async () => { + // Arrange + mockOnboardingComplete = false; + + // Act + const result = await invokeHandler('onboarding:complete'); + + // Assert + expect(result).toBe(false); + }); + + it('onboarding:complete should return true when completed', async () => { + // Arrange + mockOnboardingComplete = true; + + // Act + const result = await invokeHandler('onboarding:complete'); + + // Assert + expect(result).toBe(true); + }); + + it('onboarding:complete should return true if user has task history', async () => { + // Arrange + mockOnboardingComplete = false; + mockTasks.push({ + id: 'existing_task', + prompt: 'Existing task', + status: 'completed', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('onboarding:complete'); + + // Assert + expect(result).toBe(true); + }); + + it('onboarding:set-complete should update onboarding status', async () => { + // Arrange + mockOnboardingComplete = false; + + // Act + await invokeHandler('onboarding:set-complete', true); + + // Assert + const { setOnboardingComplete } = await import('@main/store/appSettings'); + expect(setOnboardingComplete).toHaveBeenCalledWith(true); + }); + }); + + describe('Permission Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('permission:respond should send response for active task', async () => { + // Arrange + const taskId = 'task_active'; + mockTaskManager.hasActiveTask.mockReturnValue(true); + + // Act + await invokeHandler('permission:respond', { + requestId: 'req_123', + taskId, + decision: 'allow', + }); + + // Assert + expect(mockTaskManager.sendResponse).toHaveBeenCalledWith(taskId, 'yes'); + }); + + it('permission:respond should send custom message when provided', async () => { + // Arrange + const taskId = 'task_active'; + mockTaskManager.hasActiveTask.mockReturnValue(true); + + // Act + await invokeHandler('permission:respond', { + requestId: 'req_123', + taskId, + decision: 'allow', + message: 'proceed with caution', + }); + + // Assert + expect(mockTaskManager.sendResponse).toHaveBeenCalledWith(taskId, 'proceed with caution'); + }); + + it('permission:respond should send "no" for denied decisions', async () => { + // Arrange + const taskId = 'task_active'; + mockTaskManager.hasActiveTask.mockReturnValue(true); + + // Act + await invokeHandler('permission:respond', { + requestId: 'req_123', + taskId, + decision: 'deny', + }); + + // Assert + expect(mockTaskManager.sendResponse).toHaveBeenCalledWith(taskId, 'no'); + }); + + it('permission:respond should resolve file permission requests', async () => { + // Arrange + const requestId = 'filereq_123_abc'; + const taskId = 'task_active'; + + // Simulate pending file permission + mockPendingPermissions.set(requestId, { resolve: vi.fn() }); + + // Act + await invokeHandler('permission:respond', { + requestId, + taskId, + decision: 'allow', + }); + + // Assert + const { resolvePermission } = await import('@main/permission-api'); + expect(resolvePermission).toHaveBeenCalledWith(requestId, true); + }); + + it('permission:respond should skip response for inactive task', async () => { + // Arrange + const taskId = 'task_inactive'; + mockTaskManager.hasActiveTask.mockReturnValue(false); + + // Act + await invokeHandler('permission:respond', { + requestId: 'req_123', + taskId, + decision: 'allow', + }); + + // Assert + expect(mockTaskManager.sendResponse).not.toHaveBeenCalled(); + }); + }); + + describe('Model Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('model:get should return selected model', async () => { + // Arrange + mockSelectedModel = { provider: 'anthropic', model: 'claude-3-sonnet' }; + + // Act + const result = await invokeHandler('model:get'); + + // Assert + expect(result).toEqual({ provider: 'anthropic', model: 'claude-3-sonnet' }); + }); + + it('model:get should return null when no model selected', async () => { + // Arrange + mockSelectedModel = null; + + // Act + const result = await invokeHandler('model:get'); + + // Assert + expect(result).toBeNull(); + }); + + it('model:set should update selected model', async () => { + // Arrange + const newModel = { provider: 'openai', model: 'gpt-4' }; + + // Act + await invokeHandler('model:set', newModel); + + // Assert + const { setSelectedModel } = await import('@main/store/appSettings'); + expect(setSelectedModel).toHaveBeenCalledWith(newModel); + }); + + it('model:set should reject invalid model configuration', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('model:set', null)).rejects.toThrow( + 'Invalid model configuration' + ); + await expect(invokeHandler('model:set', { provider: 'test' })).rejects.toThrow( + 'Invalid model configuration' + ); + await expect(invokeHandler('model:set', { model: 'test' })).rejects.toThrow( + 'Invalid model configuration' + ); + }); + }); + + describe('Shell Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('shell:open-external should open valid http URL', async () => { + // Arrange + const url = 'https://example.com'; + + // Act + await invokeHandler('shell:open-external', url); + + // Assert + expect(shell.openExternal).toHaveBeenCalledWith(url); + }); + + it('shell:open-external should open valid https URL', async () => { + // Arrange + const url = 'http://localhost:3000'; + + // Act + await invokeHandler('shell:open-external', url); + + // Assert + expect(shell.openExternal).toHaveBeenCalledWith(url); + }); + + it('shell:open-external should reject non-http/https protocols', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('shell:open-external', 'file:///etc/passwd')).rejects.toThrow( + 'Only http and https URLs are allowed' + ); + await expect(invokeHandler('shell:open-external', 'javascript:alert(1)')).rejects.toThrow( + 'Only http and https URLs are allowed' + ); + }); + + it('shell:open-external should reject invalid URLs', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('shell:open-external', 'not-a-url')).rejects.toThrow(); + }); + }); + + describe('OpenCode Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('opencode:check should return CLI status', async () => { + // Arrange - mocked to return installed + + // Act + const result = (await invokeHandler('opencode:check')) as { + installed: boolean; + version: string; + installCommand: string; + }; + + // Assert + expect(result).toEqual( + expect.objectContaining({ + installed: true, + version: '1.0.0', + installCommand: 'npm install -g opencode-ai', + }) + ); + }); + + it('opencode:version should return CLI version', async () => { + // Arrange - mocked to return version + + // Act + const result = await invokeHandler('opencode:version'); + + // Assert + expect(result).toBe('1.0.0'); + }); + }); + + describe('Multi-Provider API Key Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('api-keys:all should return masked keys for all providers', async () => { + // Arrange + mockApiKeys = { + anthropic: 'sk-ant-12345678', + openai: null, + google: 'AIza1234567890', + xai: null, + custom: null, + }; + + // Act + const result = (await invokeHandler('api-keys:all')) as Record< + string, + { exists: boolean; prefix?: string } + >; + + // Assert + expect(result.anthropic).toEqual({ + exists: true, + prefix: 'sk-ant-1...', + }); + expect(result.openai).toEqual({ exists: false, prefix: undefined }); + expect(result.google).toEqual({ + exists: true, + prefix: 'AIza1234...', + }); + }); + + it('api-keys:has-any should return true when any key exists', async () => { + // Arrange + mockApiKeys['anthropic'] = 'sk-test'; + + // Act + const result = await invokeHandler('api-keys:has-any'); + + // Assert + expect(result).toBe(true); + }); + + it('api-keys:has-any should return false when no keys exist', async () => { + // Arrange - no keys + + // Act + const result = await invokeHandler('api-keys:has-any'); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('Session Handlers', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('session:resume should start a new task with session ID', async () => { + // Arrange + const sessionId = 'session_123'; + const prompt = 'Continue with the task'; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_resumed', + prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('session:resume', sessionId, prompt); + + // Assert + expect(mockTaskManager.startTask).toHaveBeenCalledWith( + expect.stringMatching(/^task_/), + expect.objectContaining({ + prompt, + sessionId, + }), + expect.any(Object) + ); + expect(result).toEqual( + expect.objectContaining({ + prompt, + status: 'running', + }) + ); + }); + + it('session:resume should use existing task ID when provided', async () => { + // Arrange + const sessionId = 'session_123'; + const prompt = 'Continue'; + const existingTaskId = 'task_existing'; + mockTaskManager.startTask.mockResolvedValue({ + id: existingTaskId, + prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('session:resume', sessionId, prompt, existingTaskId); + + // Assert + expect(mockTaskManager.startTask).toHaveBeenCalledWith( + existingTaskId, + expect.objectContaining({ + prompt, + sessionId, + taskId: existingTaskId, + }), + expect.any(Object) + ); + }); + + it('session:resume should validate session ID', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('session:resume', '', 'prompt')).rejects.toThrow(); + await expect(invokeHandler('session:resume', ' ', 'prompt')).rejects.toThrow(); + }); + + it('session:resume should validate prompt', async () => { + // Arrange & Act & Assert + await expect(invokeHandler('session:resume', 'session_123', '')).rejects.toThrow(); + await expect(invokeHandler('session:resume', 'session_123', ' ')).rejects.toThrow(); + }); + }); + + describe('Log Event Handler', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('log:event should return ok response', async () => { + // Arrange + const payload = { + level: 'info', + message: 'Test log message', + context: { key: 'value' }, + }; + + // Act + const result = await invokeHandler('log:event', payload); + + // Assert + expect(result).toEqual({ ok: true }); + }); + }); + + describe('Task Callbacks and Message Batching', () => { + beforeEach(() => { + registerIPCHandlers(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('task:start should initialize permission API on first call', async () => { + // Arrange + const config = { prompt: 'Test task prompt' }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_123', + prompt: 'Test task prompt', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('task:start', config); + + // Assert + const { initPermissionApi, startPermissionApiServer } = await import('@main/permission-api'); + expect(initPermissionApi).toHaveBeenCalled(); + expect(startPermissionApiServer).toHaveBeenCalled(); + }); + + it('task:start should only initialize permission API once', async () => { + // Arrange + const config = { prompt: 'Test task' }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_1', + prompt: 'Test task', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act - start two tasks + await invokeHandler('task:start', config); + await invokeHandler('task:start', { prompt: 'Second task' }); + + // Assert - should only be called once + const { initPermissionApi } = await import('@main/permission-api'); + expect(initPermissionApi).toHaveBeenCalledTimes(1); + }); + + it('task:start should create initial user message', async () => { + // Arrange + const config = { prompt: 'My test prompt' }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_msg', + prompt: 'My test prompt', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('task:start', config) as { + id: string; + messages: Array<{ type: string; content: string }>; + }; + + // Assert + expect(result.messages).toHaveLength(1); + expect(result.messages[0].type).toBe('user'); + expect(result.messages[0].content).toBe('My test prompt'); + }); + + it('task:start should save task to history', async () => { + // Arrange + const config = { prompt: 'Save me' }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_save', + prompt: 'Save me', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('task:start', config); + + // Assert + const { saveTask } = await import('@main/store/taskHistory'); + expect(saveTask).toHaveBeenCalled(); + }); + + it('task:start should validate all optional config fields', async () => { + // Arrange + const config = { + prompt: 'Full config test', + taskId: 'custom_task_id', + sessionId: 'custom_session', + workingDirectory: '/some/path', + allowedTools: ['tool1', 'tool2', 123, null], // Should filter non-strings + systemPromptAppend: 'Additional instructions', + outputSchema: { type: 'object' }, + }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_full', + prompt: 'Full config test', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('task:start', config); + + // Assert + expect(mockTaskManager.startTask).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + prompt: 'Full config test', + taskId: 'custom_task_id', + sessionId: 'custom_session', + workingDirectory: '/some/path', + allowedTools: ['tool1', 'tool2'], // Non-strings filtered + systemPromptAppend: 'Additional instructions', + outputSchema: { type: 'object' }, + }), + expect.any(Object) + ); + }); + + it('task:start should truncate allowedTools array to 20 items', async () => { + // Arrange + const manyTools = Array.from({ length: 30 }, (_, i) => `tool${i}`); + const config = { + prompt: 'Many tools test', + allowedTools: manyTools, + }; + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_tools', + prompt: 'Many tools test', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('task:start', config); + + // Assert + expect(mockTaskManager.startTask).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + allowedTools: expect.any(Array), + }), + expect.any(Object) + ); + const callArgs = mockTaskManager.startTask.mock.calls[0][1]; + expect(callArgs.allowedTools.length).toBe(20); + }); + + it('task:cancel should do nothing when taskId is undefined', async () => { + // Arrange & Act + await invokeHandler('task:cancel', undefined); + + // Assert + expect(mockTaskManager.cancelTask).not.toHaveBeenCalled(); + expect(mockTaskManager.cancelQueuedTask).not.toHaveBeenCalled(); + }); + + it('task:interrupt should do nothing when taskId is undefined', async () => { + // Arrange & Act + await invokeHandler('task:interrupt', undefined); + + // Assert + expect(mockTaskManager.interruptTask).not.toHaveBeenCalled(); + }); + + it('task:interrupt should do nothing for inactive task', async () => { + // Arrange + mockTaskManager.hasActiveTask.mockReturnValue(false); + + // Act + await invokeHandler('task:interrupt', 'task_inactive'); + + // Assert + expect(mockTaskManager.interruptTask).not.toHaveBeenCalled(); + }); + }); + + describe('Session Resume with Existing Task', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('session:resume should add user message to existing task', async () => { + // Arrange + const sessionId = 'session_existing'; + const prompt = 'Follow-up message'; + const existingTaskId = 'task_existing'; + + mockTaskManager.startTask.mockResolvedValue({ + id: existingTaskId, + prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('session:resume', sessionId, prompt, existingTaskId); + + // Assert + const { addTaskMessage } = await import('@main/store/taskHistory'); + expect(addTaskMessage).toHaveBeenCalledWith( + existingTaskId, + expect.objectContaining({ + type: 'user', + content: prompt, + }) + ); + }); + + it('session:resume should update task status in history', async () => { + // Arrange + const sessionId = 'session_status'; + const prompt = 'Status update test'; + const existingTaskId = 'task_status'; + + mockTaskManager.startTask.mockResolvedValue({ + id: existingTaskId, + prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('session:resume', sessionId, prompt, existingTaskId); + + // Assert + const { updateTaskStatus } = await import('@main/store/taskHistory'); + expect(updateTaskStatus).toHaveBeenCalledWith( + existingTaskId, + 'running', + expect.any(String) + ); + }); + + it('session:resume should not add message when no existing task ID', async () => { + // Arrange + const sessionId = 'session_new'; + const prompt = 'New session'; + + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_new', + prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + await invokeHandler('session:resume', sessionId, prompt); + + // Assert + const { addTaskMessage } = await import('@main/store/taskHistory'); + // Should not be called for new tasks + expect(addTaskMessage).not.toHaveBeenCalledWith( + undefined, + expect.anything() + ); + }); + }); + + describe('Permission Response Edge Cases', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('permission:respond should use selectedOptions when provided', async () => { + // Arrange + const taskId = 'task_options'; + mockTaskManager.hasActiveTask.mockReturnValue(true); + + // Act + await invokeHandler('permission:respond', { + requestId: 'req_456', + taskId, + decision: 'allow', + selectedOptions: ['option1', 'option2', 'option3'], + }); + + // Assert + expect(mockTaskManager.sendResponse).toHaveBeenCalledWith( + taskId, + 'option1, option2, option3' + ); + }); + + it('permission:respond should log when file permission not found', async () => { + // Arrange + const taskId = 'task_notfound'; + mockTaskManager.hasActiveTask.mockReturnValue(false); + // File permission request that is not in pending + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Act + await invokeHandler('permission:respond', { + requestId: 'filereq_notfound', + taskId, + decision: 'allow', + }); + + // Assert + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('File permission request') + ); + consoleSpy.mockRestore(); + }); + }); + + describe('Window Trust Validation', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('should throw error when window is destroyed', async () => { + // Arrange + const { BrowserWindow } = await import('electron'); + (BrowserWindow.fromWebContents as Mock).mockReturnValue({ + id: 1, + isDestroyed: () => true, + webContents: { send: vi.fn(), isDestroyed: () => true }, + }); + + // Act & Assert + await expect( + invokeHandler('task:start', { prompt: 'Test' }) + ).rejects.toThrow('Untrusted window'); + }); + + it('should throw error when window is null', async () => { + // Arrange + const { BrowserWindow } = await import('electron'); + (BrowserWindow.fromWebContents as Mock).mockReturnValue(null); + + // Act & Assert + await expect( + invokeHandler('task:start', { prompt: 'Test' }) + ).rejects.toThrow('Untrusted window'); + }); + + it('should throw error when IPC from non-focused window with multiple windows', async () => { + // Arrange + const { BrowserWindow } = await import('electron'); + (BrowserWindow.fromWebContents as Mock).mockReturnValue({ + id: 2, // Different from focused window + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false }, + }); + (BrowserWindow.getFocusedWindow as Mock).mockReturnValue({ + id: 1, // Different ID + isDestroyed: () => false, + }); + (BrowserWindow.getAllWindows as Mock).mockReturnValue([{ id: 1 }, { id: 2 }]); + + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_test', + prompt: 'Test', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act & Assert + await expect( + invokeHandler('task:start', { prompt: 'Test' }) + ).rejects.toThrow('IPC request must originate from the focused window'); + }); + + it('should allow IPC when only one window exists', async () => { + // Arrange + const { BrowserWindow } = await import('electron'); + (BrowserWindow.fromWebContents as Mock).mockReturnValue({ + id: 1, + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false }, + }); + (BrowserWindow.getFocusedWindow as Mock).mockReturnValue({ + id: 2, // Different but only one window + isDestroyed: () => false, + }); + (BrowserWindow.getAllWindows as Mock).mockReturnValue([{ id: 1 }]); // Only one window + + mockTaskManager.startTask.mockResolvedValue({ + id: 'task_single', + prompt: 'Test', + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }); + + // Act + const result = await invokeHandler('task:start', { prompt: 'Test' }); + + // Assert + expect(result).toBeDefined(); + }); + }); + + describe('E2E Skip Auth Mode', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('onboarding:complete should return true when E2E_SKIP_AUTH env is set', async () => { + // Arrange + const originalEnv = process.env.E2E_SKIP_AUTH; + process.env.E2E_SKIP_AUTH = '1'; + + // Act + const result = await invokeHandler('onboarding:complete'); + + // Assert + expect(result).toBe(true); + + // Cleanup + process.env.E2E_SKIP_AUTH = originalEnv; + }); + + it('opencode:check should return mock status when E2E_SKIP_AUTH is set', async () => { + // Arrange + const originalEnv = process.env.E2E_SKIP_AUTH; + process.env.E2E_SKIP_AUTH = '1'; + + // Act + const result = await invokeHandler('opencode:check') as { + installed: boolean; + version: string; + }; + + // Assert + expect(result.installed).toBe(true); + expect(result.version).toBe('1.0.0-test'); + + // Cleanup + process.env.E2E_SKIP_AUTH = originalEnv; + }); + }); + + describe('API Key Validation Timeout', () => { + beforeEach(() => { + registerIPCHandlers(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('api-key:validate should handle abort error', async () => { + // Arrange + vi.stubGlobal('fetch', vi.fn().mockImplementation(() => { + const abortError = new Error('Request aborted'); + abortError.name = 'AbortError'; + return Promise.reject(abortError); + })); + + // Act + const result = await invokeHandler('api-key:validate', 'sk-test-key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('api-key:validate should handle network errors', async () => { + // Arrange + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); + + // Act + const result = await invokeHandler('api-key:validate', 'sk-test-key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toContain('Failed to validate'); + }); + + it('api-key:validate should return invalid for non-200 response', async () => { + // Arrange + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: { message: 'Invalid API key' } }), + })); + + // Act + const result = await invokeHandler('api-key:validate', 'sk-test-key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid API key'); + }); + + it('api-key:validate should return valid for 200 response', async () => { + // Arrange + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + })); + + // Act + const result = await invokeHandler('api-key:validate', 'sk-test-key') as { + valid: boolean; + }; + + // Assert + expect(result.valid).toBe(true); + }); + }); + + describe('Multi-Provider API Key Validation', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('api-key:validate-provider should reject unsupported provider', async () => { + // Act + const result = await invokeHandler('api-key:validate-provider', 'invalid-provider', 'key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toBe('Unsupported provider'); + }); + + it('api-key:validate-provider should skip validation for custom provider', async () => { + // Act + const result = await invokeHandler('api-key:validate-provider', 'custom', 'any-key') as { + valid: boolean; + }; + + // Assert + expect(result.valid).toBe(true); + }); + + it('api-key:validate-provider should validate OpenAI key', async () => { + // Arrange + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', mockFetch); + + // Act + const result = await invokeHandler('api-key:validate-provider', 'openai', 'sk-openai-key') as { + valid: boolean; + }; + + // Assert + expect(result.valid).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1/models', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer sk-openai-key', + }), + }) + ); + }); + + it('api-key:validate-provider should validate Google key', async () => { + // Arrange + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', mockFetch); + + // Act + const result = await invokeHandler('api-key:validate-provider', 'google', 'AIza-test-key') as { + valid: boolean; + }; + + // Assert + expect(result.valid).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + 'https://generativelanguage.googleapis.com/v1beta/models?key=AIza-test-key', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + + it('api-key:validate-provider should handle AbortError', async () => { + // Arrange + const abortError = new Error('Request aborted'); + abortError.name = 'AbortError'; + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)); + + // Act + const result = await invokeHandler('api-key:validate-provider', 'openai', 'sk-key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toContain('timed out'); + }); + + it('api-key:validate-provider should handle failed response with error message', async () => { + // Arrange + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({ error: { message: 'Access denied' } }), + })); + + // Act + const result = await invokeHandler('api-key:validate-provider', 'openai', 'sk-bad-key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toBe('Access denied'); + }); + + it('api-key:validate-provider should handle failed response without error message', async () => { + // Arrange + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('Invalid JSON')), + })); + + // Act + const result = await invokeHandler('api-key:validate-provider', 'openai', 'sk-key') as { + valid: boolean; + error: string; + }; + + // Assert + expect(result.valid).toBe(false); + expect(result.error).toContain('API returned status 500'); + }); + }); + + describe('Settings Add API Key with Label', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('settings:add-api-key should accept and return custom label', async () => { + // Arrange + const provider = 'anthropic'; + const key = 'sk-custom-labeled-key'; + const label = 'My Production Key'; + + // Act + const result = await invokeHandler('settings:add-api-key', provider, key, label) as { + label: string; + }; + + // Assert + expect(result.label).toBe('My Production Key'); + }); + + it('settings:add-api-key should use default label when not provided', async () => { + // Arrange + const provider = 'anthropic'; + const key = 'sk-no-label-key'; + + // Act + const result = await invokeHandler('settings:add-api-key', provider, key) as { + label: string; + }; + + // Assert + expect(result.label).toBe('Local API Key'); + }); + + it('settings:add-api-key should validate label length', async () => { + // Arrange + const provider = 'anthropic'; + const key = 'sk-valid-key'; + const longLabel = 'x'.repeat(200); + + // Act & Assert + await expect( + invokeHandler('settings:add-api-key', provider, key, longLabel) + ).rejects.toThrow('exceeds maximum length'); + }); + }); + + describe('Settings API Keys with Empty Password', () => { + beforeEach(() => { + registerIPCHandlers(); + }); + + it('settings:api-keys should handle empty password', async () => { + // Arrange + mockStoredCredentials = [ + { account: 'apiKey:anthropic', password: '' }, + ]; + + // Act + const result = await invokeHandler('settings:api-keys') as Array<{ keyPrefix: string }>; + + // Assert + expect(result).toHaveLength(1); + expect(result[0].keyPrefix).toBe(''); + }); + }); + + // Note: Callback execution tests for onStatusChange, onDebug, onError, onComplete + // are complex to set up due to vitest mock hoisting for webContents.send. + // The callback logic is exercised through the task lifecycle tests above. + // The utility functions (extractScreenshots, sanitizeToolOutput, toTaskMessage) + // are tested in handlers-utils.unit.test.ts as pure function tests. +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts new file mode 100644 index 000000000..f9e8d005b --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts @@ -0,0 +1,856 @@ +/** + * Unit tests for OpenCode Adapter + * + * Tests the adapter module which manages PTY spawning, stream parsing, + * and event handling for OpenCode CLI interactions. + * + * NOTE: This is a UNIT test, not an integration test. + * External dependencies (node-pty, fs, child_process) are mocked to test + * adapter logic in isolation. Internal modules (secureStorage, appSettings, + * config-generator) are also mocked since this tests the adapter's behavior + * independent of those implementations. + * + * Mocked external services: + * - node-pty: External process spawning (PTY terminal) + * - electron: Native desktop APIs + * - child_process: Process execution + * + * @module __tests__/unit/main/opencode/adapter.unit.test + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { + OpenCodeStepStartMessage, + OpenCodeTextMessage, + OpenCodeToolCallMessage, + OpenCodeToolUseMessage, + OpenCodeStepFinishMessage, + OpenCodeErrorMessage, +} from '@accomplish/shared'; + +// Mock electron module +const mockApp = { + isPackaged: false, + getAppPath: vi.fn(() => '/mock/app/path'), + getPath: vi.fn((name: string) => `/mock/path/${name}`), +}; + +vi.mock('electron', () => ({ + app: mockApp, +})); + +// Mock fs module +const mockFs = { + existsSync: vi.fn(() => true), + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), +}; + +vi.mock('fs', () => ({ + default: mockFs, + existsSync: mockFs.existsSync, + readdirSync: mockFs.readdirSync, + readFileSync: mockFs.readFileSync, + mkdirSync: mockFs.mkdirSync, + writeFileSync: mockFs.writeFileSync, +})); + +// Create a mock PTY process +class MockPty extends EventEmitter { + pid = 12345; + killed = false; + + write = vi.fn(); + kill = vi.fn(() => { + this.killed = true; + }); + + // Helper to simulate data events + simulateData(data: string) { + const callbacks = this.listeners('data'); + callbacks.forEach((cb) => (cb as (data: string) => void)(data)); + } + + // Helper to simulate exit + simulateExit(exitCode: number, signal?: number) { + const callbacks = this.listeners('exit'); + callbacks.forEach((cb) => (cb as (params: { exitCode: number; signal?: number }) => void)({ exitCode, signal })); + } + + // Override on to use onData/onExit interface + onData(callback: (data: string) => void) { + this.on('data', callback); + return { dispose: () => this.off('data', callback) }; + } + + onExit(callback: (params: { exitCode: number; signal?: number }) => void) { + this.on('exit', callback); + return { dispose: () => this.off('exit', callback) }; + } +} + +// Mock node-pty +const mockPtyInstance = new MockPty(); +const mockPtySpawn = vi.fn(() => mockPtyInstance); + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn, +})); + +// Mock child_process for execSync +vi.mock('child_process', () => ({ + execSync: vi.fn(() => '/usr/local/bin/opencode'), +})); + +// Mock secure storage +vi.mock('@main/store/secureStorage', () => ({ + getAllApiKeys: vi.fn(() => Promise.resolve({ + anthropic: 'test-anthropic-key', + openai: 'test-openai-key', + })), + getBedrockCredentials: vi.fn(() => null), +})); + +// Mock app settings +vi.mock('@main/store/appSettings', () => ({ + getSelectedModel: vi.fn(() => ({ model: 'claude-3-opus-20240229' })), +})); + +// Mock config generator +vi.mock('@main/opencode/config-generator', () => ({ + generateOpenCodeConfig: vi.fn(() => Promise.resolve('/mock/config/path')), + syncApiKeysToOpenCodeAuth: vi.fn(() => Promise.resolve()), + ACCOMPLISH_AGENT_NAME: 'accomplish', +})); + +// Mock system-path +vi.mock('@main/utils/system-path', () => ({ + getExtendedNodePath: vi.fn((basePath: string) => basePath || '/usr/bin'), +})); + +// Mock bundled-node +vi.mock('@main/utils/bundled-node', () => ({ + getBundledNodePaths: vi.fn(() => null), + logBundledNodeInfo: vi.fn(), +})); + +// Mock permission-api +vi.mock('@main/permission-api', () => ({ + PERMISSION_API_PORT: 9999, +})); + +describe('OpenCode Adapter Module', () => { + let OpenCodeAdapter: typeof import('@main/opencode/adapter').OpenCodeAdapter; + let createAdapter: typeof import('@main/opencode/adapter').createAdapter; + let isOpenCodeCliInstalled: typeof import('@main/opencode/adapter').isOpenCodeCliInstalled; + let getOpenCodeCliVersion: typeof import('@main/opencode/adapter').getOpenCodeCliVersion; + let OpenCodeCliNotFoundError: typeof import('@main/opencode/adapter').OpenCodeCliNotFoundError; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Create a fresh mock PTY for each test + Object.assign(mockPtyInstance, new MockPty()); + mockPtyInstance.killed = false; + mockPtyInstance.removeAllListeners(); + + // Re-import module to get fresh state + const module = await import('@main/opencode/adapter'); + OpenCodeAdapter = module.OpenCodeAdapter; + createAdapter = module.createAdapter; + isOpenCodeCliInstalled = module.isOpenCodeCliInstalled; + getOpenCodeCliVersion = module.getOpenCodeCliVersion; + OpenCodeCliNotFoundError = module.OpenCodeCliNotFoundError; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + describe('OpenCodeAdapter Class', () => { + describe('Constructor', () => { + it('should create adapter instance with optional task ID', () => { + // Act + const adapter = new OpenCodeAdapter('test-task-123'); + + // Assert + expect(adapter.getTaskId()).toBe('test-task-123'); + expect(adapter.isAdapterDisposed()).toBe(false); + }); + + it('should create adapter instance without task ID', () => { + // Act + const adapter = new OpenCodeAdapter(); + + // Assert + expect(adapter.getTaskId()).toBeNull(); + }); + }); + + describe('startTask()', () => { + it('should spawn PTY process with correct arguments', async () => { + // Arrange + const adapter = new OpenCodeAdapter('test-task'); + const config = { + prompt: 'Test prompt', + taskId: 'test-task-123', + }; + + // Act + const task = await adapter.startTask(config); + + // Assert + expect(mockPtySpawn).toHaveBeenCalled(); + expect(task.id).toBe('test-task-123'); + expect(task.prompt).toBe('Test prompt'); + expect(task.status).toBe('running'); + }); + + it('should generate task ID if not provided', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const config = { prompt: 'Test prompt' }; + + // Act + const task = await adapter.startTask(config); + + // Assert + expect(task.id).toMatch(/^task_\d+_[a-z0-9]+$/); + }); + + it('should emit debug events during startup', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const debugEvents: Array<{ type: string; message: string }> = []; + adapter.on('debug', (log) => debugEvents.push(log)); + + // Act + await adapter.startTask({ prompt: 'Test' }); + + // Assert + expect(debugEvents.length).toBeGreaterThan(0); + expect(debugEvents.some((e) => e.type === 'info')).toBe(true); + }); + + it('should throw error if adapter is disposed', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + adapter.dispose(); + + // Act & Assert + await expect(adapter.startTask({ prompt: 'Test' })).rejects.toThrow( + 'Adapter has been disposed' + ); + }); + }); + + describe('Event Emission', () => { + it('should emit message event when receiving text message', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const messages: unknown[] = []; + adapter.on('message', (msg) => messages.push(msg)); + + await adapter.startTask({ prompt: 'Test' }); + + const textMessage: OpenCodeTextMessage = { + type: 'text', + part: { + id: 'msg-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'text', + text: 'Hello, I am assisting you.', + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(textMessage) + '\n'); + + // Assert + expect(messages.length).toBe(1); + expect(messages[0]).toMatchObject({ type: 'text' }); + }); + + it('should emit progress event on step_start message', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const progressEvents: Array<{ stage: string; message?: string }> = []; + adapter.on('progress', (p) => progressEvents.push(p)); + + await adapter.startTask({ prompt: 'Test' }); + + const stepStartMessage: OpenCodeStepStartMessage = { + type: 'step_start', + part: { + id: 'step-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'step-start', + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(stepStartMessage) + '\n'); + + // Assert + expect(progressEvents.length).toBe(1); + expect(progressEvents[0].stage).toBe('init'); + }); + + it('should emit tool-use event on tool_call message', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const toolEvents: Array<[string, unknown]> = []; + adapter.on('tool-use', (name, input) => toolEvents.push([name, input])); + + await adapter.startTask({ prompt: 'Test' }); + + const toolCallMessage: OpenCodeToolCallMessage = { + type: 'tool_call', + part: { + id: 'tool-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'tool-call', + tool: 'Bash', + input: { command: 'ls -la' }, + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(toolCallMessage) + '\n'); + + // Assert + expect(toolEvents.length).toBe(1); + expect(toolEvents[0][0]).toBe('Bash'); + expect(toolEvents[0][1]).toEqual({ command: 'ls -la' }); + }); + + it('should emit tool-use and tool-result events on tool_use message', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const toolUseEvents: Array<[string, unknown]> = []; + const toolResultEvents: string[] = []; + adapter.on('tool-use', (name, input) => toolUseEvents.push([name, input])); + adapter.on('tool-result', (output) => toolResultEvents.push(output)); + + await adapter.startTask({ prompt: 'Test' }); + + const toolUseMessage: OpenCodeToolUseMessage = { + type: 'tool_use', + part: { + id: 'tool-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'tool', + tool: 'Read', + state: { + status: 'completed', + input: { path: '/test/file.txt' }, + output: 'File contents here', + }, + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(toolUseMessage) + '\n'); + + // Assert + expect(toolUseEvents.length).toBe(1); + expect(toolUseEvents[0][0]).toBe('Read'); + expect(toolResultEvents.length).toBe(1); + expect(toolResultEvents[0]).toBe('File contents here'); + }); + + it('should emit complete event on step_finish with stop reason', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const completeEvents: Array<{ status: string; sessionId?: string }> = []; + adapter.on('complete', (result) => completeEvents.push(result)); + + await adapter.startTask({ prompt: 'Test' }); + + const stepFinishMessage: OpenCodeStepFinishMessage = { + type: 'step_finish', + part: { + id: 'step-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'step-finish', + reason: 'stop', + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(stepFinishMessage) + '\n'); + + // Assert + expect(completeEvents.length).toBe(1); + expect(completeEvents[0].status).toBe('success'); + }); + + it('should not emit complete event on step_finish with tool_use reason', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const completeEvents: Array<{ status: string }> = []; + adapter.on('complete', (result) => completeEvents.push(result)); + + await adapter.startTask({ prompt: 'Test' }); + + const stepFinishMessage: OpenCodeStepFinishMessage = { + type: 'step_finish', + part: { + id: 'step-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'step-finish', + reason: 'tool_use', + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(stepFinishMessage) + '\n'); + + // Assert + expect(completeEvents.length).toBe(0); + }); + + it('should emit complete with error status on error message', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const completeEvents: Array<{ status: string; error?: string }> = []; + adapter.on('complete', (result) => completeEvents.push(result)); + + await adapter.startTask({ prompt: 'Test' }); + + const errorMessage: OpenCodeErrorMessage = { + type: 'error', + error: 'Something went wrong', + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(errorMessage) + '\n'); + + // Assert + expect(completeEvents.length).toBe(1); + expect(completeEvents[0].status).toBe('error'); + expect(completeEvents[0].error).toBe('Something went wrong'); + }); + + it('should emit permission-request event for AskUserQuestion tool', async () => { + // Arrange + const adapter = new OpenCodeAdapter('test-task'); + const permissionRequests: unknown[] = []; + adapter.on('permission-request', (req) => permissionRequests.push(req)); + + await adapter.startTask({ prompt: 'Test' }); + + const toolCallMessage: OpenCodeToolCallMessage = { + type: 'tool_call', + part: { + id: 'tool-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'tool-call', + tool: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Do you want to proceed?', + options: [ + { label: 'Yes', description: 'Proceed with action' }, + { label: 'No', description: 'Cancel' }, + ], + }, + ], + }, + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(toolCallMessage) + '\n'); + + // Assert + expect(permissionRequests.length).toBe(1); + const req = permissionRequests[0] as { question: string; options: Array<{ label: string }> }; + expect(req.question).toBe('Do you want to proceed?'); + expect(req.options).toHaveLength(2); + }); + }); + + describe('Stream Parser Integration', () => { + it('should handle multiple JSON messages in single data chunk', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const messages: unknown[] = []; + adapter.on('message', (msg) => messages.push(msg)); + + await adapter.startTask({ prompt: 'Test' }); + + const message1: OpenCodeTextMessage = { + type: 'text', + part: { id: '1', sessionID: 's', messageID: 'm', type: 'text', text: 'First' }, + }; + const message2: OpenCodeTextMessage = { + type: 'text', + part: { id: '2', sessionID: 's', messageID: 'm', type: 'text', text: 'Second' }, + }; + + // Act + mockPtyInstance.simulateData( + JSON.stringify(message1) + '\n' + JSON.stringify(message2) + '\n' + ); + + // Assert + expect(messages.length).toBe(2); + }); + + it('should handle split JSON messages across data chunks', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const messages: unknown[] = []; + adapter.on('message', (msg) => messages.push(msg)); + + await adapter.startTask({ prompt: 'Test' }); + + const fullMessage: OpenCodeTextMessage = { + type: 'text', + part: { id: '1', sessionID: 's', messageID: 'm', type: 'text', text: 'Complete message' }, + }; + const jsonStr = JSON.stringify(fullMessage); + const splitPoint = Math.floor(jsonStr.length / 2); + + // Act - send message in two parts + mockPtyInstance.simulateData(jsonStr.substring(0, splitPoint)); + mockPtyInstance.simulateData(jsonStr.substring(splitPoint) + '\n'); + + // Assert + expect(messages.length).toBe(1); + }); + + it('should skip non-JSON lines without crashing', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const messages: unknown[] = []; + const debugEvents: unknown[] = []; + adapter.on('message', (msg) => messages.push(msg)); + adapter.on('debug', (d) => debugEvents.push(d)); + + await adapter.startTask({ prompt: 'Test' }); + + const validMessage: OpenCodeTextMessage = { + type: 'text', + part: { id: '1', sessionID: 's', messageID: 'm', type: 'text', text: 'Valid' }, + }; + + // Act - send non-JSON followed by valid JSON + mockPtyInstance.simulateData('Shell banner: Welcome to zsh\n'); + mockPtyInstance.simulateData(JSON.stringify(validMessage) + '\n'); + + // Assert + expect(messages.length).toBe(1); + }); + + it('should strip ANSI escape codes from data', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const messages: unknown[] = []; + adapter.on('message', (msg) => messages.push(msg)); + + await adapter.startTask({ prompt: 'Test' }); + + const validMessage: OpenCodeTextMessage = { + type: 'text', + part: { id: '1', sessionID: 's', messageID: 'm', type: 'text', text: 'Valid' }, + }; + + // Act - send JSON with ANSI codes + const ansiWrapped = '\x1B[32m' + JSON.stringify(validMessage) + '\x1B[0m\n'; + mockPtyInstance.simulateData(ansiWrapped); + + // Assert + expect(messages.length).toBe(1); + }); + }); + + describe('Process Exit Handling', () => { + it('should emit complete on normal exit (code 0)', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const completeEvents: Array<{ status: string }> = []; + adapter.on('complete', (result) => completeEvents.push(result)); + + await adapter.startTask({ prompt: 'Test' }); + + // Act + mockPtyInstance.simulateExit(0); + + // Assert + expect(completeEvents.length).toBe(1); + expect(completeEvents[0].status).toBe('success'); + }); + + it('should emit error on non-zero exit code', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const errorEvents: Error[] = []; + adapter.on('error', (err) => errorEvents.push(err)); + + await adapter.startTask({ prompt: 'Test' }); + + // Act + mockPtyInstance.simulateExit(1); + + // Assert + expect(errorEvents.length).toBe(1); + expect(errorEvents[0].message).toContain('exited with code 1'); + }); + + it('should emit interrupted status when interrupted', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const completeEvents: Array<{ status: string }> = []; + adapter.on('complete', (result) => completeEvents.push(result)); + + await adapter.startTask({ prompt: 'Test' }); + + // Act + await adapter.interruptTask(); + mockPtyInstance.simulateExit(0); + + // Assert + expect(completeEvents.length).toBe(1); + expect(completeEvents[0].status).toBe('interrupted'); + }); + + it('should not emit duplicate complete events', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + const completeEvents: Array<{ status: string }> = []; + adapter.on('complete', (result) => completeEvents.push(result)); + + await adapter.startTask({ prompt: 'Test' }); + + // Emit step_finish first (marks hasCompleted = true) + const stepFinish: OpenCodeStepFinishMessage = { + type: 'step_finish', + part: { + id: 'step-1', + sessionID: 'session-123', + messageID: 'message-123', + type: 'step-finish', + reason: 'stop', + }, + }; + mockPtyInstance.simulateData(JSON.stringify(stepFinish) + '\n'); + + // Act - then exit + mockPtyInstance.simulateExit(0); + + // Assert - should only have one complete event + expect(completeEvents.length).toBe(1); + }); + }); + + describe('sendResponse()', () => { + it('should write response to PTY', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + await adapter.startTask({ prompt: 'Test' }); + + // Act + await adapter.sendResponse('user input'); + + // Assert + expect(mockPtyInstance.write).toHaveBeenCalledWith('user input\n'); + }); + + it('should throw error if no active process', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + // Don't start a task + + // Act & Assert + await expect(adapter.sendResponse('input')).rejects.toThrow('No active process'); + }); + }); + + describe('cancelTask()', () => { + it('should kill PTY process', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + await adapter.startTask({ prompt: 'Test' }); + + // Act + await adapter.cancelTask(); + + // Assert + expect(mockPtyInstance.kill).toHaveBeenCalled(); + }); + }); + + describe('interruptTask()', () => { + it('should send Ctrl+C to PTY', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + await adapter.startTask({ prompt: 'Test' }); + + // Act + await adapter.interruptTask(); + + // Assert + expect(mockPtyInstance.write).toHaveBeenCalledWith('\x03'); + }); + + it('should handle interrupt when no active process', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + // Don't start a task + + // Act - should not throw + await adapter.interruptTask(); + + // Assert + expect(mockPtyInstance.write).not.toHaveBeenCalled(); + }); + }); + + describe('dispose()', () => { + it('should cleanup PTY process and state', async () => { + // Arrange + const adapter = new OpenCodeAdapter('test-task'); + await adapter.startTask({ prompt: 'Test' }); + + // Act + adapter.dispose(); + + // Assert + expect(adapter.isAdapterDisposed()).toBe(true); + expect(adapter.getTaskId()).toBeNull(); + expect(adapter.getSessionId()).toBeNull(); + expect(mockPtyInstance.kill).toHaveBeenCalled(); + }); + + it('should be idempotent (safe to call multiple times)', () => { + // Arrange + const adapter = new OpenCodeAdapter(); + + // Act - call dispose multiple times + adapter.dispose(); + adapter.dispose(); + adapter.dispose(); + + // Assert - should not throw + expect(adapter.isAdapterDisposed()).toBe(true); + }); + + it('should remove all event listeners', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + let messageCount = 0; + adapter.on('message', () => messageCount++); + await adapter.startTask({ prompt: 'Test' }); + + // Act + adapter.dispose(); + adapter.emit('message', {} as OpenCodeTextMessage); + + // Assert - listener should have been removed + expect(messageCount).toBe(0); + }); + }); + + describe('Session Management', () => { + it('should track session ID from step_start message', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + await adapter.startTask({ prompt: 'Test' }); + + const stepStart: OpenCodeStepStartMessage = { + type: 'step_start', + part: { + id: 'step-1', + sessionID: 'session-abc-123', + messageID: 'message-123', + type: 'step-start', + }, + }; + + // Act + mockPtyInstance.simulateData(JSON.stringify(stepStart) + '\n'); + + // Assert + expect(adapter.getSessionId()).toBe('session-abc-123'); + }); + + it('should support resuming sessions', async () => { + // Arrange + const adapter = new OpenCodeAdapter(); + + // Act + const task = await adapter.resumeSession('existing-session', 'Continue task'); + + // Assert + expect(task.prompt).toBe('Continue task'); + expect(mockPtySpawn).toHaveBeenCalled(); + }); + }); + }); + + describe('Factory Functions', () => { + describe('createAdapter()', () => { + it('should create a new adapter instance', () => { + // Act + const adapter = createAdapter('task-123'); + + // Assert + expect(adapter).toBeInstanceOf(OpenCodeAdapter); + expect(adapter.getTaskId()).toBe('task-123'); + }); + }); + + describe('isOpenCodeCliInstalled()', () => { + it('should return boolean indicating CLI availability', async () => { + // Act + const result = await isOpenCodeCliInstalled(); + + // Assert + expect(typeof result).toBe('boolean'); + }); + }); + + describe('getOpenCodeCliVersion()', () => { + it('should return version string or null', async () => { + // Act + const result = await getOpenCodeCliVersion(); + + // Assert + expect(result === null || typeof result === 'string').toBe(true); + }); + }); + }); + + describe('OpenCodeCliNotFoundError', () => { + it('should have correct error name', () => { + // Act + const error = new OpenCodeCliNotFoundError(); + + // Assert + expect(error.name).toBe('OpenCodeCliNotFoundError'); + }); + + it('should have descriptive message', () => { + // Act + const error = new OpenCodeCliNotFoundError(); + + // Assert + expect(error.message).toContain('OpenCode CLI is not available'); + expect(error.message).toContain('reinstall'); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts new file mode 100644 index 000000000..3b9a769e8 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts @@ -0,0 +1,727 @@ +/** + * Unit tests for Task Manager + * + * Tests the task-manager module which handles task lifecycle, parallel execution, + * queueing, and cleanup of OpenCode adapter instances. + * + * NOTE: This is a UNIT test, not an integration test. + * The OpenCode adapter is replaced with a mock (MockOpenCodeAdapter) to test + * task manager logic in isolation. This allows testing task lifecycle, queueing, + * and event handling without spawning real PTY processes. + * + * Mocked components: + * - OpenCode adapter: Simulated adapter behavior + * - electron: Native desktop APIs + * - fs/os: File system operations + * + * @module __tests__/unit/main/opencode/task-manager.unit.test + */ + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import type { TaskConfig, TaskResult, OpenCodeMessage, PermissionRequest } from '@accomplish/shared'; + +// Mock electron module +const mockApp = { + isPackaged: false, + getAppPath: vi.fn(() => '/mock/app/path'), + getPath: vi.fn((name: string) => `/mock/path/${name}`), +}; + +vi.mock('electron', () => ({ + app: mockApp, +})); + +// Mock fs module +const mockFs = { + existsSync: vi.fn(() => false), + readdirSync: vi.fn(() => []), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), +}; + +vi.mock('fs', () => ({ + default: mockFs, + existsSync: mockFs.existsSync, + readdirSync: mockFs.readdirSync, + readFileSync: mockFs.readFileSync, + mkdirSync: mockFs.mkdirSync, + writeFileSync: mockFs.writeFileSync, +})); + +// Mock os module +vi.mock('os', () => ({ + default: { homedir: () => '/Users/testuser' }, + homedir: () => '/Users/testuser', +})); + +// Create a mock adapter class +class MockOpenCodeAdapter extends EventEmitter { + private taskId: string | null = null; + private sessionId: string | null = null; + private disposed = false; + private startTaskFn: (config: TaskConfig) => Promise<{ id: string; prompt: string; status: string; messages: never[]; createdAt: string }>; + + constructor(taskId?: string) { + super(); + this.taskId = taskId || null; + this.startTaskFn = vi.fn(async (config: TaskConfig) => { + this.taskId = config.taskId || `task_${Date.now()}`; + this.sessionId = `session_${Date.now()}`; + return { + id: this.taskId, + prompt: config.prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }; + }); + } + + getTaskId() { + return this.taskId; + } + + getSessionId() { + return this.sessionId; + } + + isAdapterDisposed() { + return this.disposed; + } + + async startTask(config: TaskConfig) { + return this.startTaskFn(config); + } + + async cancelTask() { + this.emit('complete', { status: 'cancelled' }); + } + + async interruptTask() { + this.emit('complete', { status: 'interrupted' }); + } + + async sendResponse(response: string) { + // Mock response handling + return response; + } + + dispose() { + this.disposed = true; + this.removeAllListeners(); + } + + // Test helpers + simulateComplete(result: TaskResult) { + this.emit('complete', result); + } + + simulateError(error: Error) { + this.emit('error', error); + } + + simulateMessage(message: OpenCodeMessage) { + this.emit('message', message); + } + + simulateProgress(progress: { stage: string; message?: string }) { + this.emit('progress', progress); + } + + simulatePermissionRequest(request: PermissionRequest) { + this.emit('permission-request', request); + } +} + +// Track created adapters for testing +const createdAdapters: MockOpenCodeAdapter[] = []; + +// Mock the adapter module +vi.mock('@main/opencode/adapter', () => ({ + OpenCodeAdapter: MockOpenCodeAdapter, + isOpenCodeCliInstalled: vi.fn(() => Promise.resolve(true)), + OpenCodeCliNotFoundError: class OpenCodeCliNotFoundError extends Error { + constructor() { + super('OpenCode CLI is not available'); + this.name = 'OpenCodeCliNotFoundError'; + } + }, +})); + +// Mock config generator +vi.mock('@main/opencode/config-generator', () => ({ + getSkillsPath: vi.fn(() => '/mock/skills/path'), + generateOpenCodeConfig: vi.fn(() => Promise.resolve('/mock/config')), + ACCOMPLISH_AGENT_NAME: 'accomplish', +})); + +// Mock bundled-node +vi.mock('@main/utils/bundled-node', () => ({ + getNpxPath: vi.fn(() => '/mock/npx'), + getBundledNodePaths: vi.fn(() => null), +})); + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(() => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, callback: (code: number) => void) => { + if (event === 'close') { + setTimeout(() => callback(0), 10); + } + }), + unref: vi.fn(), + })), +})); + +describe('Task Manager Module', () => { + let TaskManager: typeof import('@main/opencode/task-manager').TaskManager; + let getTaskManager: typeof import('@main/opencode/task-manager').getTaskManager; + let disposeTaskManager: typeof import('@main/opencode/task-manager').disposeTaskManager; + + // Helper to create mock callbacks + function createMockCallbacks() { + return { + onMessage: vi.fn(), + onProgress: vi.fn(), + onPermissionRequest: vi.fn(), + onComplete: vi.fn(), + onError: vi.fn(), + onStatusChange: vi.fn(), + onDebug: vi.fn(), + }; + } + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + createdAdapters.length = 0; + + // Re-import module to get fresh state + const module = await import('@main/opencode/task-manager'); + TaskManager = module.TaskManager; + getTaskManager = module.getTaskManager; + disposeTaskManager = module.disposeTaskManager; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('TaskManager Class', () => { + describe('Constructor', () => { + it('should create task manager with default max concurrent tasks', () => { + // Act + const manager = new TaskManager(); + + // Assert + expect(manager.getActiveTaskCount()).toBe(0); + expect(manager.getQueueLength()).toBe(0); + }); + + it('should create task manager with custom max concurrent tasks', () => { + // Arrange & Act + const manager = new TaskManager({ maxConcurrentTasks: 5 }); + + // Assert - verify by filling up to the limit + expect(manager.getActiveTaskCount()).toBe(0); + }); + }); + + describe('startTask()', () => { + it('should start a single task successfully', async () => { + // Arrange + const manager = new TaskManager(); + const callbacks = createMockCallbacks(); + const config: TaskConfig = { prompt: 'Test task' }; + + // Act + const task = await manager.startTask('task-1', config, callbacks); + + // Assert + expect(task.id).toBe('task-1'); + expect(task.status).toBe('running'); + expect(manager.hasActiveTask('task-1')).toBe(true); + expect(manager.getActiveTaskCount()).toBe(1); + }); + + it('should throw error if task ID already exists', async () => { + // Arrange + const manager = new TaskManager(); + const callbacks = createMockCallbacks(); + const config: TaskConfig = { prompt: 'Test task' }; + + await manager.startTask('task-1', config, callbacks); + + // Act & Assert + await expect( + manager.startTask('task-1', config, createMockCallbacks()) + ).rejects.toThrow('already running or queued'); + }); + + it('should execute multiple tasks in parallel up to limit', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 3 }); + + // Act + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + await manager.startTask('task-3', { prompt: 'Task 3' }, createMockCallbacks()); + + // Assert + expect(manager.getActiveTaskCount()).toBe(3); + expect(manager.getQueueLength()).toBe(0); + expect(manager.hasActiveTask('task-1')).toBe(true); + expect(manager.hasActiveTask('task-2')).toBe(true); + expect(manager.hasActiveTask('task-3')).toBe(true); + }); + + it('should queue tasks when at capacity', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 2 }); + + // Act + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + const task3 = await manager.startTask('task-3', { prompt: 'Task 3' }, createMockCallbacks()); + + // Assert + expect(manager.getActiveTaskCount()).toBe(2); + expect(manager.getQueueLength()).toBe(1); + expect(task3.status).toBe('queued'); + expect(manager.isTaskQueued('task-3')).toBe(true); + }); + + it('should throw error when queue is full', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + // Act & Assert + await expect( + manager.startTask('task-3', { prompt: 'Task 3' }, createMockCallbacks()) + ).rejects.toThrow('Maximum queued tasks'); + }); + + it('should return queue position for queued tasks', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + // Act + const position = manager.getQueuePosition('task-2'); + + // Assert + expect(position).toBe(1); + }); + + it('should return 0 for non-queued task position', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + + // Act + const position = manager.getQueuePosition('task-1'); + + // Assert + expect(position).toBe(0); + }); + }); + + describe('Task Event Handling', () => { + it('should forward message events to callbacks', async () => { + // Arrange + const manager = new TaskManager(); + const callbacks = createMockCallbacks(); + await manager.startTask('task-1', { prompt: 'Test' }, callbacks); + + // Note: In real implementation, adapter events would be forwarded + // This tests the callback wiring + expect(callbacks.onMessage).not.toHaveBeenCalled(); // No messages yet + }); + + it('should forward progress events to callbacks', async () => { + // Arrange + const manager = new TaskManager(); + const callbacks = createMockCallbacks(); + await manager.startTask('task-1', { prompt: 'Test' }, callbacks); + + // Progress is emitted during browser setup + // Wait a bit for async operations + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Assert - progress should be called during startup + // Note: Exact number depends on browser detection + expect(callbacks.onProgress).toHaveBeenCalled(); + }); + + it('should cleanup task on completion and process queue', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + const callbacks1 = createMockCallbacks(); + const callbacks2 = createMockCallbacks(); + + await manager.startTask('task-1', { prompt: 'Task 1' }, callbacks1); + await manager.startTask('task-2', { prompt: 'Task 2' }, callbacks2); + + expect(manager.getActiveTaskCount()).toBe(1); + expect(manager.getQueueLength()).toBe(1); + + // Act - simulate task-1 completion + // In real implementation, this would be triggered by adapter event + // For this test, we verify the manager state after operations + expect(manager.hasActiveTask('task-1')).toBe(true); + }); + + it('should cleanup task on error and process queue', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + const callbacks1 = createMockCallbacks(); + const callbacks2 = createMockCallbacks(); + + await manager.startTask('task-1', { prompt: 'Task 1' }, callbacks1); + await manager.startTask('task-2', { prompt: 'Task 2' }, callbacks2); + + // Assert initial state + expect(manager.hasActiveTask('task-1')).toBe(true); + expect(manager.isTaskQueued('task-2')).toBe(true); + }); + }); + + describe('cancelTask()', () => { + it('should cancel a running task', async () => { + // Arrange + const manager = new TaskManager(); + const callbacks = createMockCallbacks(); + await manager.startTask('task-1', { prompt: 'Test' }, callbacks); + + // Act + await manager.cancelTask('task-1'); + + // Assert + expect(manager.hasActiveTask('task-1')).toBe(false); + }); + + it('should cancel a queued task', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + expect(manager.isTaskQueued('task-2')).toBe(true); + + // Act + await manager.cancelTask('task-2'); + + // Assert + expect(manager.isTaskQueued('task-2')).toBe(false); + expect(manager.getQueueLength()).toBe(0); + }); + + it('should handle cancellation of non-existent task gracefully', async () => { + // Arrange + const manager = new TaskManager(); + + // Act & Assert - should not throw + await manager.cancelTask('non-existent'); + }); + + it('should process queue after cancellation', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + const callbacks2 = createMockCallbacks(); + + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, callbacks2); + + // Act + await manager.cancelTask('task-1'); + + // Wait for queue processing + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Assert - task-2 should now be active + expect(manager.getQueueLength()).toBe(0); + }); + }); + + describe('interruptTask()', () => { + it('should interrupt a running task', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Test' }, createMockCallbacks()); + + // Act & Assert - should not throw + await manager.interruptTask('task-1'); + }); + + it('should handle interruption of non-existent task gracefully', async () => { + // Arrange + const manager = new TaskManager(); + + // Act & Assert - should not throw + await manager.interruptTask('non-existent'); + }); + }); + + describe('cancelQueuedTask()', () => { + it('should remove task from queue and return true', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + // Act + const result = manager.cancelQueuedTask('task-2'); + + // Assert + expect(result).toBe(true); + expect(manager.getQueueLength()).toBe(0); + }); + + it('should return false for non-queued task', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Test' }, createMockCallbacks()); + + // Act + const result = manager.cancelQueuedTask('task-1'); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('sendResponse()', () => { + it('should send response to active task', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Test' }, createMockCallbacks()); + + // Act & Assert - should not throw + await manager.sendResponse('task-1', 'user response'); + }); + + it('should throw error for non-existent task', async () => { + // Arrange + const manager = new TaskManager(); + + // Act & Assert + await expect(manager.sendResponse('non-existent', 'response')).rejects.toThrow( + 'not found or not active' + ); + }); + }); + + describe('getSessionId()', () => { + it('should return session ID for active task after adapter starts', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Test' }, createMockCallbacks()); + + // Wait for async adapter initialization + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Act + const sessionId = manager.getSessionId('task-1'); + + // Assert - session ID may or may not be set depending on adapter state + // The important thing is that the method doesn't throw and returns expected type + expect(sessionId === null || typeof sessionId === 'string').toBe(true); + }); + + it('should return null for non-existent task', () => { + // Arrange + const manager = new TaskManager(); + + // Act + const sessionId = manager.getSessionId('non-existent'); + + // Assert + expect(sessionId).toBeNull(); + }); + }); + + describe('State Query Methods', () => { + it('should report hasRunningTask correctly', async () => { + // Arrange + const manager = new TaskManager(); + + // Assert initial state + expect(manager.hasRunningTask()).toBe(false); + + // Act + await manager.startTask('task-1', { prompt: 'Test' }, createMockCallbacks()); + + // Assert + expect(manager.hasRunningTask()).toBe(true); + }); + + it('should return all active task IDs', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 3 }); + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + // Act + const activeIds = manager.getActiveTaskIds(); + + // Assert + expect(activeIds).toContain('task-1'); + expect(activeIds).toContain('task-2'); + expect(activeIds.length).toBe(2); + }); + + it('should return first active task ID', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Test' }, createMockCallbacks()); + + // Act + const activeId = manager.getActiveTaskId(); + + // Assert + expect(activeId).toBe('task-1'); + }); + + it('should return null when no active tasks', () => { + // Arrange + const manager = new TaskManager(); + + // Act + const activeId = manager.getActiveTaskId(); + + // Assert + expect(activeId).toBeNull(); + }); + }); + + describe('dispose()', () => { + it('should dispose all active tasks', async () => { + // Arrange + const manager = new TaskManager(); + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + // Act + manager.dispose(); + + // Assert + expect(manager.getActiveTaskCount()).toBe(0); + expect(manager.hasRunningTask()).toBe(false); + }); + + it('should clear the task queue', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 1 }); + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + + expect(manager.getQueueLength()).toBe(1); + + // Act + manager.dispose(); + + // Assert + expect(manager.getQueueLength()).toBe(0); + }); + }); + }); + + describe('Singleton Functions', () => { + describe('getTaskManager()', () => { + it('should return singleton instance', () => { + // Act + const manager1 = getTaskManager(); + const manager2 = getTaskManager(); + + // Assert + expect(manager1).toBe(manager2); + }); + + it('should create new instance if none exists', () => { + // Act + disposeTaskManager(); + const manager = getTaskManager(); + + // Assert + expect(manager).toBeInstanceOf(TaskManager); + }); + }); + + describe('disposeTaskManager()', () => { + it('should dispose singleton and allow recreation', () => { + // Arrange + const manager1 = getTaskManager(); + + // Act + disposeTaskManager(); + const manager2 = getTaskManager(); + + // Assert + expect(manager2).not.toBe(manager1); + }); + + it('should be safe to call multiple times', () => { + // Act & Assert - should not throw + disposeTaskManager(); + disposeTaskManager(); + disposeTaskManager(); + }); + }); + }); + + describe('Queue Processing', () => { + it('should queue tasks and track positions correctly', async () => { + // Arrange - use maxConcurrentTasks: 2 to allow queue limit of 2 + const manager = new TaskManager({ maxConcurrentTasks: 2 }); + + const callbacks1 = createMockCallbacks(); + const callbacks2 = createMockCallbacks(); + const callbacks3 = createMockCallbacks(); + const callbacks4 = createMockCallbacks(); + + // Start tasks - first 2 run, next 2 queue + await manager.startTask('task-1', { prompt: 'Task 1' }, callbacks1); + await manager.startTask('task-2', { prompt: 'Task 2' }, callbacks2); + await manager.startTask('task-3', { prompt: 'Task 3' }, callbacks3); + await manager.startTask('task-4', { prompt: 'Task 4' }, callbacks4); + + // Assert queue state + expect(manager.getActiveTaskCount()).toBe(2); + expect(manager.getQueueLength()).toBe(2); + expect(manager.getQueuePosition('task-3')).toBe(1); + expect(manager.getQueuePosition('task-4')).toBe(2); + }); + + it('should maintain queue integrity during concurrent operations', async () => { + // Arrange + const manager = new TaskManager({ maxConcurrentTasks: 2 }); + + // Add multiple tasks + await manager.startTask('task-1', { prompt: 'Task 1' }, createMockCallbacks()); + await manager.startTask('task-2', { prompt: 'Task 2' }, createMockCallbacks()); + await manager.startTask('task-3', { prompt: 'Task 3' }, createMockCallbacks()); + await manager.startTask('task-4', { prompt: 'Task 4' }, createMockCallbacks()); + + // Assert + expect(manager.getActiveTaskCount()).toBe(2); + expect(manager.getQueueLength()).toBe(2); + + // Cancel queued task + const removed = manager.cancelQueuedTask('task-3'); + expect(removed).toBe(true); + expect(manager.getQueueLength()).toBe(1); + + // task-4 should still be queued + expect(manager.isTaskQueued('task-4')).toBe(true); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts new file mode 100644 index 000000000..ff647538d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts @@ -0,0 +1,234 @@ +/** + * Unit tests for Accomplish API library + * + * Tests the Electron detection and shell utilities: + * - isRunningInElectron() detection + * - getShellVersion() retrieval + * - getShellPlatform() retrieval + * - getAccomplish() and useAccomplish() API access + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Store original window +const originalWindow = globalThis.window; + +describe('Accomplish API', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + (globalThis as unknown as { window: Record }).window = {}; + }); + + afterEach(() => { + vi.clearAllMocks(); + (globalThis as unknown as { window: typeof window }).window = originalWindow; + }); + + describe('isRunningInElectron', () => { + it('should return true when accomplishShell.isElectron is true', async () => { + (globalThis as unknown as { window: { accomplishShell: { isElectron: boolean } } }).window = { + accomplishShell: { isElectron: true }, + }; + + const { isRunningInElectron } = await import('@renderer/lib/accomplish'); + expect(isRunningInElectron()).toBe(true); + }); + + it('should return false when accomplishShell.isElectron is false', async () => { + (globalThis as unknown as { window: { accomplishShell: { isElectron: boolean } } }).window = { + accomplishShell: { isElectron: false }, + }; + + const { isRunningInElectron } = await import('@renderer/lib/accomplish'); + expect(isRunningInElectron()).toBe(false); + }); + + it('should return false when accomplishShell is unavailable', async () => { + // Test undefined, null, missing property, and empty object + const unavailableScenarios = [ + { accomplishShell: undefined }, + { accomplishShell: null }, + { accomplishShell: { version: '1.0.0' } }, // missing isElectron + {}, // no accomplishShell at all + ]; + + for (const scenario of unavailableScenarios) { + vi.resetModules(); + (globalThis as unknown as { window: Record }).window = scenario; + const { isRunningInElectron } = await import('@renderer/lib/accomplish'); + expect(isRunningInElectron()).toBe(false); + } + }); + + it('should use strict equality for isElectron check', async () => { + // Truthy but not true should return false + (globalThis as unknown as { window: { accomplishShell: { isElectron: number } } }).window = { + accomplishShell: { isElectron: 1 }, + }; + + const { isRunningInElectron } = await import('@renderer/lib/accomplish'); + expect(isRunningInElectron()).toBe(false); + }); + }); + + describe('getShellVersion', () => { + it('should return version when available', async () => { + (globalThis as unknown as { window: { accomplishShell: { version: string } } }).window = { + accomplishShell: { version: '1.2.3' }, + }; + + const { getShellVersion } = await import('@renderer/lib/accomplish'); + expect(getShellVersion()).toBe('1.2.3'); + }); + + it('should return null when version is unavailable', async () => { + const unavailableScenarios = [ + { accomplishShell: undefined }, + { accomplishShell: { isElectron: true } }, // no version property + {}, + ]; + + for (const scenario of unavailableScenarios) { + vi.resetModules(); + (globalThis as unknown as { window: Record }).window = scenario; + const { getShellVersion } = await import('@renderer/lib/accomplish'); + expect(getShellVersion()).toBeNull(); + } + }); + + it('should handle various version formats', async () => { + const versions = ['0.0.1', '1.0.0', '2.5.10', '1.0.0-beta.1', '1.0.0-rc.2']; + + for (const version of versions) { + vi.resetModules(); + (globalThis as unknown as { window: { accomplishShell: { version: string } } }).window = { + accomplishShell: { version }, + }; + const { getShellVersion } = await import('@renderer/lib/accomplish'); + expect(getShellVersion()).toBe(version); + } + }); + }); + + describe('getShellPlatform', () => { + it('should return platform when available', async () => { + const platforms = ['darwin', 'linux', 'win32']; + + for (const platform of platforms) { + vi.resetModules(); + (globalThis as unknown as { window: { accomplishShell: { platform: string } } }).window = { + accomplishShell: { platform }, + }; + const { getShellPlatform } = await import('@renderer/lib/accomplish'); + expect(getShellPlatform()).toBe(platform); + } + }); + + it('should return null when platform is unavailable', async () => { + const unavailableScenarios = [ + { accomplishShell: undefined }, + { accomplishShell: { isElectron: true } }, // no platform property + {}, + ]; + + for (const scenario of unavailableScenarios) { + vi.resetModules(); + (globalThis as unknown as { window: Record }).window = scenario; + const { getShellPlatform } = await import('@renderer/lib/accomplish'); + expect(getShellPlatform()).toBeNull(); + } + }); + }); + + describe('getAccomplish', () => { + it('should return accomplish API when available', async () => { + const mockApi = { + getVersion: vi.fn(), + startTask: vi.fn(), + validateBedrockCredentials: vi.fn(), + saveBedrockCredentials: vi.fn(), + getBedrockCredentials: vi.fn(), + }; + (globalThis as unknown as { window: { accomplish: typeof mockApi } }).window = { + accomplish: mockApi, + }; + + const { getAccomplish } = await import('@renderer/lib/accomplish'); + const result = getAccomplish(); + // getAccomplish returns a wrapper object with spread methods + Bedrock wrappers + expect(result.getVersion).toBeDefined(); + expect(result.startTask).toBeDefined(); + expect(result.validateBedrockCredentials).toBeDefined(); + expect(result.saveBedrockCredentials).toBeDefined(); + expect(result.getBedrockCredentials).toBeDefined(); + }); + + it('should throw when accomplish API is not available', async () => { + const unavailableScenarios = [ + { accomplish: undefined }, + {}, + ]; + + for (const scenario of unavailableScenarios) { + vi.resetModules(); + (globalThis as unknown as { window: Record }).window = scenario; + const { getAccomplish } = await import('@renderer/lib/accomplish'); + expect(() => getAccomplish()).toThrow('Accomplish API not available - not running in Electron'); + } + }); + }); + + describe('useAccomplish', () => { + it('should return accomplish API when available', async () => { + const mockApi = { getVersion: vi.fn(), startTask: vi.fn() }; + (globalThis as unknown as { window: { accomplish: typeof mockApi } }).window = { + accomplish: mockApi, + }; + + const { useAccomplish } = await import('@renderer/lib/accomplish'); + expect(useAccomplish()).toBe(mockApi); + }); + + it('should throw when accomplish API is not available', async () => { + (globalThis as unknown as { window: { accomplish?: unknown } }).window = { + accomplish: undefined, + }; + + const { useAccomplish } = await import('@renderer/lib/accomplish'); + expect(() => useAccomplish()).toThrow('Accomplish API not available - not running in Electron'); + }); + }); + + describe('Complete Shell Object', () => { + it('should recognize complete shell object with all properties', async () => { + const completeShell = { + version: '1.0.0', + platform: 'darwin', + isElectron: true as const, + }; + (globalThis as unknown as { window: { accomplishShell: typeof completeShell } }).window = { + accomplishShell: completeShell, + }; + + const { isRunningInElectron, getShellVersion, getShellPlatform } = await import('@renderer/lib/accomplish'); + + expect(isRunningInElectron()).toBe(true); + expect(getShellVersion()).toBe('1.0.0'); + expect(getShellPlatform()).toBe('darwin'); + }); + + it('should handle partial shell object gracefully', async () => { + const partialShell = { version: '1.0.0', isElectron: true as const }; + (globalThis as unknown as { window: { accomplishShell: typeof partialShell } }).window = { + accomplishShell: partialShell, + }; + + const { isRunningInElectron, getShellVersion, getShellPlatform } = await import('@renderer/lib/accomplish'); + + expect(isRunningInElectron()).toBe(true); + expect(getShellVersion()).toBe('1.0.0'); + expect(getShellPlatform()).toBeNull(); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts new file mode 100644 index 000000000..ae505cb3a --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts @@ -0,0 +1,281 @@ +/** + * Unit tests for Analytics library + * + * Tests the analytics tracking utilities: + * - trackPageView() and trackEvent() behavior + * - No-op behavior when gtag is unavailable + * - Correct gtag calls when available + * - All predefined event trackers in the analytics object + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock window.gtag before importing the module +const mockGtag = vi.fn(); + +// Set up window mock +const originalWindow = globalThis.window; + +describe('Analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + (globalThis as unknown as { window: typeof window }).window = { + ...originalWindow, + gtag: undefined, + } as unknown as typeof window; + }); + + afterEach(() => { + vi.clearAllMocks(); + (globalThis as unknown as { window: typeof window }).window = originalWindow; + }); + + describe('trackPageView', () => { + it('should not throw when gtag is unavailable', async () => { + (globalThis as unknown as { window: { gtag?: unknown } }).window = { gtag: undefined }; + + const { trackPageView } = await import('@renderer/lib/analytics'); + // Should not throw - just returns without error + expect(() => trackPageView('/test-page', 'Test Page')).not.toThrow(); + }); + + it('should call gtag with correct parameters when available', async () => { + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + + const { trackPageView } = await import('@renderer/lib/analytics'); + trackPageView('/test-page', 'Test Page'); + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-RQWHYJ5NEG', { + page_path: '/test-page', + page_title: 'Test Page', + }); + }); + + it('should handle missing page title', async () => { + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + + const { trackPageView } = await import('@renderer/lib/analytics'); + trackPageView('/test-page'); + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-RQWHYJ5NEG', { + page_path: '/test-page', + page_title: undefined, + }); + }); + + it('should return immediately if gtag is not a function', async () => { + (globalThis as unknown as { window: { gtag: string } }).window = { gtag: 'not a function' }; + + const { trackPageView } = await import('@renderer/lib/analytics'); + const result = trackPageView('/test-page'); + + expect(result).toBeUndefined(); + }); + }); + + describe('trackEvent', () => { + it('should not throw when gtag is unavailable', async () => { + (globalThis as unknown as { window: { gtag?: unknown } }).window = { gtag: undefined }; + + const { trackEvent } = await import('@renderer/lib/analytics'); + expect(() => trackEvent('test_event', { category: 'test' })).not.toThrow(); + }); + + it('should call gtag with event name and parameters', async () => { + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + + const { trackEvent } = await import('@renderer/lib/analytics'); + trackEvent('test_event', { category: 'test', value: 123 }); + + expect(mockGtag).toHaveBeenCalledWith('event', 'test_event', { + category: 'test', + value: 123, + }); + }); + + it('should handle events without parameters', async () => { + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + + const { trackEvent } = await import('@renderer/lib/analytics'); + trackEvent('simple_event'); + + expect(mockGtag).toHaveBeenCalledWith('event', 'simple_event', undefined); + }); + }); + + describe('Predefined Event Trackers', () => { + beforeEach(async () => { + vi.resetModules(); + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + }); + + it('trackSubmitTask should call gtag with correct parameters', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackSubmitTask(); + + expect(mockGtag).toHaveBeenCalledWith('event', 'submit_task', { + event_category: 'engagement', + event_label: 'task_submission', + }); + }); + + it('trackNewTask should call gtag with correct parameters', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackNewTask(); + + expect(mockGtag).toHaveBeenCalledWith('event', 'new_task', { + event_category: 'engagement', + event_label: 'new_task_click', + }); + }); + + it('trackOpenSettings should call gtag with correct parameters', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackOpenSettings(); + + expect(mockGtag).toHaveBeenCalledWith('event', 'open_settings', { + event_category: 'engagement', + event_label: 'settings_click', + }); + }); + + it('trackSaveApiKey should include provider parameter', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackSaveApiKey('anthropic'); + + expect(mockGtag).toHaveBeenCalledWith('event', 'save_api_key', { + event_category: 'settings', + event_label: 'api_key_save', + provider: 'anthropic', + }); + }); + + it('trackSelectProvider should include provider parameter', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackSelectProvider('openai'); + + expect(mockGtag).toHaveBeenCalledWith('event', 'select_provider', { + event_category: 'settings', + event_label: 'provider_selection', + provider: 'openai', + }); + }); + + it('trackSelectModel should include model parameter', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackSelectModel('claude-3-sonnet'); + + expect(mockGtag).toHaveBeenCalledWith('event', 'select_model', { + event_category: 'settings', + event_label: 'model_selection', + model: 'claude-3-sonnet', + }); + }); + + it('trackToggleDebugMode should include enabled flag', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + + analytics.trackToggleDebugMode(true); + expect(mockGtag).toHaveBeenCalledWith('event', 'toggle_debug_mode', { + event_category: 'settings', + event_label: 'debug_mode_toggle', + enabled: true, + }); + + mockGtag.mockClear(); + + analytics.trackToggleDebugMode(false); + expect(mockGtag).toHaveBeenCalledWith('event', 'toggle_debug_mode', { + event_category: 'settings', + event_label: 'debug_mode_toggle', + enabled: false, + }); + }); + }); + + describe('Analytics Object Structure', () => { + it('should expose all required tracker functions', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + + expect(typeof analytics.trackPageView).toBe('function'); + expect(typeof analytics.trackEvent).toBe('function'); + expect(typeof analytics.trackSubmitTask).toBe('function'); + expect(typeof analytics.trackNewTask).toBe('function'); + expect(typeof analytics.trackOpenSettings).toBe('function'); + expect(typeof analytics.trackSaveApiKey).toBe('function'); + expect(typeof analytics.trackSelectProvider).toBe('function'); + expect(typeof analytics.trackSelectModel).toBe('function'); + expect(typeof analytics.trackToggleDebugMode).toBe('function'); + }); + + it('should export analytics object as default', async () => { + const analyticsDefault = (await import('@renderer/lib/analytics')).default; + expect(analyticsDefault).toBeDefined(); + expect(analyticsDefault.trackSubmitTask).toBeDefined(); + expect(analyticsDefault.trackPageView).toBeDefined(); + }); + }); + + describe('Event Categories', () => { + beforeEach(async () => { + vi.resetModules(); + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + }); + + it('should use engagement category for user actions', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackSubmitTask(); + analytics.trackNewTask(); + analytics.trackOpenSettings(); + + const engagementCalls = mockGtag.mock.calls.filter( + (call) => call[2]?.event_category === 'engagement' + ); + expect(engagementCalls).toHaveLength(3); + }); + + it('should use settings category for configuration changes', async () => { + const { analytics } = await import('@renderer/lib/analytics'); + analytics.trackSaveApiKey('anthropic'); + analytics.trackSelectProvider('openai'); + analytics.trackSelectModel('gpt-4'); + analytics.trackToggleDebugMode(true); + + const settingsCalls = mockGtag.mock.calls.filter( + (call) => call[2]?.event_category === 'settings' + ); + expect(settingsCalls).toHaveLength(4); + }); + }); + + describe('Edge Cases', () => { + it('should handle null gtag gracefully', async () => { + (globalThis as unknown as { window: { gtag: null } }).window = { gtag: null }; + + const { trackEvent } = await import('@renderer/lib/analytics'); + expect(() => trackEvent('test')).not.toThrow(); + }); + + it('should handle empty event name', async () => { + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + + const { trackEvent } = await import('@renderer/lib/analytics'); + trackEvent(''); + + expect(mockGtag).toHaveBeenCalledWith('event', '', undefined); + }); + + it('should handle empty page path', async () => { + (globalThis as unknown as { window: { gtag: typeof mockGtag } }).window = { gtag: mockGtag }; + + const { trackPageView } = await import('@renderer/lib/analytics'); + trackPageView(''); + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-RQWHYJ5NEG', { + page_path: '', + page_title: undefined, + }); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts new file mode 100644 index 000000000..c878a3fd9 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts @@ -0,0 +1,141 @@ +/** + * Unit tests for Animation library + * + * Tests the animation configuration objects: + * - Spring configurations have expected values + * - Variants have correct initial/animate/exit states + * - Interaction presets (hover/tap) have correct scale values + */ + +import { describe, it, expect } from 'vitest'; +import { + springs, + variants, + staggerContainer, + staggerItem, + cardHover, + buttonPress, +} from '@renderer/lib/animations'; + +describe('Animation Library', () => { + describe('Spring Configurations', () => { + it('should have correct bouncy spring values', () => { + expect(springs.bouncy).toEqual({ + type: 'spring', + stiffness: 400, + damping: 25, + }); + }); + + it('should have correct gentle spring values', () => { + expect(springs.gentle).toEqual({ + type: 'spring', + stiffness: 300, + damping: 30, + }); + }); + + it('should have correct snappy spring values', () => { + expect(springs.snappy).toEqual({ + type: 'spring', + stiffness: 500, + damping: 30, + }); + }); + + it('should have valid ranges for all springs', () => { + Object.values(springs).forEach((spring) => { + expect(spring.stiffness).toBeGreaterThanOrEqual(100); + expect(spring.stiffness).toBeLessThanOrEqual(1000); + expect(spring.damping).toBeGreaterThanOrEqual(10); + expect(spring.damping).toBeLessThanOrEqual(100); + }); + }); + }); + + describe('Animation Variants', () => { + it('should have correct fadeUp values', () => { + expect(variants.fadeUp.initial).toEqual({ opacity: 0, y: 12 }); + expect(variants.fadeUp.animate).toEqual({ opacity: 1, y: 0 }); + expect(variants.fadeUp.exit).toEqual({ opacity: 0, y: -8 }); + }); + + it('should have correct fadeIn values', () => { + expect(variants.fadeIn.initial).toEqual({ opacity: 0 }); + expect(variants.fadeIn.animate).toEqual({ opacity: 1 }); + expect(variants.fadeIn.exit).toEqual({ opacity: 0 }); + }); + + it('should have correct scaleIn values', () => { + expect(variants.scaleIn.initial).toEqual({ opacity: 0, scale: 0.95 }); + expect(variants.scaleIn.animate).toEqual({ opacity: 1, scale: 1 }); + expect(variants.scaleIn.exit).toEqual({ opacity: 0, scale: 0.95 }); + }); + + it('should have correct slideInRight values', () => { + expect(variants.slideInRight.initial).toEqual({ opacity: 0, x: 20 }); + expect(variants.slideInRight.animate).toEqual({ opacity: 1, x: 0 }); + expect(variants.slideInRight.exit).toEqual({ opacity: 0, x: -20 }); + }); + + it('should have correct slideInLeft values', () => { + expect(variants.slideInLeft.initial).toEqual({ opacity: 0, x: -12 }); + expect(variants.slideInLeft.animate).toEqual({ opacity: 1, x: 0 }); + expect(variants.slideInLeft.exit).toEqual({ opacity: 0, x: -12 }); + }); + + it('should all start with opacity 0 and animate to opacity 1', () => { + Object.values(variants).forEach((variant) => { + expect((variant.initial as { opacity: number }).opacity).toBe(0); + expect((variant.animate as { opacity: number }).opacity).toBe(1); + expect((variant.exit as { opacity: number }).opacity).toBe(0); + }); + }); + }); + + describe('Stagger Animations', () => { + it('should have correct stagger container configuration', () => { + expect(staggerContainer.initial).toEqual({}); + expect(staggerContainer.animate).toEqual({ + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }); + }); + + it('should have correct stagger item configuration', () => { + expect(staggerItem.initial).toEqual({ opacity: 0, y: 8 }); + expect(staggerItem.animate).toEqual({ opacity: 1, y: 0 }); + }); + }); + + describe('Interaction Presets', () => { + it('should have correct cardHover scale values', () => { + expect(cardHover.rest).toEqual({ scale: 1 }); + expect(cardHover.hover).toEqual({ scale: 1.02 }); + expect(cardHover.tap).toEqual({ scale: 0.98 }); + }); + + it('should have correct buttonPress scale values', () => { + expect(buttonPress.rest).toEqual({ scale: 1 }); + expect(buttonPress.hover).toEqual({ scale: 1.02 }); + expect(buttonPress.tap).toEqual({ scale: 0.95 }); + }); + + it('should have button tap more pronounced than card tap', () => { + expect(buttonPress.tap.scale).toBeLessThan(cardHover.tap.scale); + }); + }); + + describe('Export Structure', () => { + it('should export all required animations', () => { + expect(Object.keys(springs)).toEqual(['bouncy', 'gentle', 'snappy']); + expect(Object.keys(variants)).toEqual(['fadeUp', 'fadeIn', 'scaleIn', 'slideInRight', 'slideInLeft']); + expect(staggerContainer).toBeDefined(); + expect(staggerItem).toBeDefined(); + expect(cardHover).toBeDefined(); + expect(buttonPress).toBeDefined(); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts new file mode 100644 index 000000000..5dd871727 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { isWaitingForUser } from '../../../../src/renderer/lib/waiting-detection'; + +describe('isWaitingForUser', () => { + describe('should return true for messages indicating waiting', () => { + // "Let me know" patterns + it.each([ + 'Let me know when you are done', + 'let me know once you have logged in', + 'Let me know after you complete the form', + 'let me know if you need help', + ])('detects "let me know" pattern: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // "Tell me" patterns + it.each([ + 'Tell me when you are ready', + 'tell me once you finish', + 'Tell me after you have entered your credentials', + ])('detects "tell me" pattern: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // "Waiting for you" patterns + it.each([ + 'I am waiting for you to complete this', + 'I will wait for your response', + "I'll wait until you are done", + 'Waiting on you to finish', + ])('detects "waiting for you" pattern: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // "Once you" / "After you" / "When you" patterns + it.each([ + "Once you've logged in, I can continue", + 'Once you have completed the form', + 'After you enter your password', + "After you've finished, click continue", + 'When you are done, let me know', + "When you've entered the code", + 'When you want to proceed', + ])('detects conditional patterns: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // "Please [action]" patterns + it.each([ + 'Please log in to continue', + 'Please login with your credentials', + 'Please sign in to your account', + 'Please enter your password', + 'Please fill out the form', + 'Please complete the verification', + 'Please click the submit button', + 'Please select an option', + 'Please confirm your identity', + 'Please verify your email', + 'Please authenticate using 2FA', + ])('detects "please" action patterns: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // Login/authentication specific + it.each([ + 'You need to log in manually', + 'Please sign in yourself', + 'Enter your credentials to proceed', + 'Enter your password in the field', + 'Enter your OTP code', + 'Authenticate yourself to continue', + 'Complete the login process', + 'Complete the authentication', + 'Complete the captcha verification', + 'Verify your identity', + 'Verify your account', + ])('detects authentication patterns: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // Manual action required + it.each([ + 'This requires manual action', + 'A manual step is needed', + 'You need to manually complete this', + 'Manually enter your details', + 'I need you to click the button', + 'This requires you to fill the form', + "You'll need to do this yourself", + 'You will need to verify', + ])('detects manual action patterns: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // Ready/done prompts + it.each([ + "When you're done, I can proceed", + 'When you are ready, continue', + 'Once done, click the button', + 'Once ready, let me know', + 'After done, we can move on', + "After you're finished", + ])('detects ready/done prompts: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // Continuation prompts + it.each([ + 'Ready to continue?', + 'Ready to proceed with the next step?', + 'Continue when you are done', + 'Proceed when ready', + 'Click continue when finished', + 'Press continue after you log in', + 'Hit continue once complete', + ])('detects continuation prompts: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + + // Explicit waiting statements + it.each([ + "I'll be here when you need me", + 'I will be here waiting', + 'Standing by for your input', + 'Awaiting your response', + 'Waiting for your input', + 'Waiting for the user to act', + 'Waiting for manual intervention', + ])('detects explicit waiting: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(true); + }); + }); + + describe('should return false for completed task messages', () => { + it.each([ + 'I have navigated to ynet.co.il', + 'Done! The page has loaded.', + 'Finished navigating to the website.', + 'Successfully opened the page.', + 'The task is complete.', + 'I clicked the button as requested.', + 'The form has been submitted.', + 'Here is the information you requested.', + 'I found the following results:', + 'The file has been saved.', + 'Screenshot captured successfully.', + '', + 'All done!', + 'Task completed successfully.', + 'Navigation complete.', + ])('returns false for: "%s"', (message) => { + expect(isWaitingForUser(message)).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for empty string', () => { + expect(isWaitingForUser('')).toBe(false); + }); + + it('returns false for null-ish content', () => { + expect(isWaitingForUser(null as unknown as string)).toBe(false); + expect(isWaitingForUser(undefined as unknown as string)).toBe(false); + }); + + it('is case insensitive', () => { + expect(isWaitingForUser('LET ME KNOW WHEN YOU ARE DONE')).toBe(true); + expect(isWaitingForUser('Please Log In')).toBe(true); + expect(isWaitingForUser('WAITING FOR YOU')).toBe(true); + }); + + it('handles multi-line messages', () => { + const multiLineWaiting = `I've opened the login page. + +Please enter your credentials and let me know when you're done.`; + expect(isWaitingForUser(multiLineWaiting)).toBe(true); + + const multiLineComplete = `I've navigated to the page. + +The content has loaded successfully.`; + expect(isWaitingForUser(multiLineComplete)).toBe(false); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/clean_dmg_install.sh b/openwork-memos-integration/apps/desktop/clean_dmg_install.sh new file mode 100755 index 000000000..986e1c9f6 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/clean_dmg_install.sh @@ -0,0 +1,229 @@ +#!/bin/bash +# Clean all files related to DMG/production installations of Accomplish +# This removes app data, preferences, caches, and optionally the app itself +# Useful for testing fresh installs or complete uninstallation + +set -e + +echo "=== ACCOMPLISH DMG INSTALLATION CLEANUP ===" +echo "" + +# Parse arguments +REMOVE_APP=false +FORCE=false + +while [[ $# -gt 0 ]]; do + case $1 in + --remove-app) + REMOVE_APP=true + shift + ;; + --force|-f) + FORCE=true + shift + ;; + --help|-h) + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " --remove-app Also remove the application from /Applications" + echo " --force, -f Skip confirmation prompts" + echo " --help, -h Show this help message" + echo "" + echo "This script cleans up all user data, caches, and preferences" + echo "for Accomplish production (DMG) installations." + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Confirm unless --force is used +if [ "$FORCE" != true ]; then + echo "This will remove all Accomplish user data including:" + echo " - App settings and task history" + echo " - Cached data and logs" + echo " - Keychain credentials" + if [ "$REMOVE_APP" = true ]; then + echo " - The Accomplish application itself" + fi + echo "" + read -p "Are you sure you want to continue? (y/N) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 + fi +fi + +echo "" + +# Kill any running instances +echo "Stopping any running Accomplish processes..." +pkill -f "Accomplish" 2>/dev/null || true +pkill -f "Accomplish Lite" 2>/dev/null || true +sleep 1 + +# Application Support directories (electron-store data) +echo "Clearing Application Support data..." +APP_SUPPORT_DIRS=( + "$HOME/Library/Application Support/Accomplish" + "$HOME/Library/Application Support/Accomplish Lite" + "$HOME/Library/Application Support/com.accomplish.desktop" + "$HOME/Library/Application Support/com.accomplish.lite" + "$HOME/Library/Application Support/ai.accomplish.desktop" + "$HOME/Library/Application Support/ai.accomplish.lite" + "$HOME/Library/Application Support/@accomplish/desktop" +) + +for dir in "${APP_SUPPORT_DIRS[@]}"; do + if [ -d "$dir" ]; then + rm -rf "$dir" + echo " - Removed: $dir" + fi +done + +# Preferences (plist files) +echo "Clearing preferences..." +PLIST_FILES=( + "$HOME/Library/Preferences/com.accomplish.desktop.plist" + "$HOME/Library/Preferences/com.accomplish.lite.plist" + "$HOME/Library/Preferences/com.accomplish.app.plist" + "$HOME/Library/Preferences/ai.accomplish.desktop.plist" + "$HOME/Library/Preferences/ai.accomplish.lite.plist" +) + +for plist in "${PLIST_FILES[@]}"; do + if [ -f "$plist" ]; then + rm -f "$plist" + echo " - Removed: $plist" + fi +done + +# Caches +echo "Clearing caches..." +CACHE_DIRS=( + "$HOME/Library/Caches/Accomplish" + "$HOME/Library/Caches/Accomplish Lite" + "$HOME/Library/Caches/com.accomplish.desktop" + "$HOME/Library/Caches/com.accomplish.lite" + "$HOME/Library/Caches/ai.accomplish.desktop" + "$HOME/Library/Caches/ai.accomplish.lite" + "$HOME/Library/Caches/@accomplish/desktop" +) + +for dir in "${CACHE_DIRS[@]}"; do + if [ -d "$dir" ]; then + rm -rf "$dir" + echo " - Removed: $dir" + fi +done + +# Logs +echo "Clearing logs..." +LOG_DIRS=( + "$HOME/Library/Logs/Accomplish" + "$HOME/Library/Logs/Accomplish Lite" + "$HOME/Library/Logs/ai.accomplish.desktop" + "$HOME/Library/Logs/ai.accomplish.lite" + "$HOME/Library/Logs/@accomplish/desktop" +) + +for dir in "${LOG_DIRS[@]}"; do + if [ -d "$dir" ]; then + rm -rf "$dir" + echo " - Removed: $dir" + fi +done + +# Saved Application State +echo "Clearing saved application state..." +SAVED_STATE_DIRS=( + "$HOME/Library/Saved Application State/com.accomplish.desktop.savedState" + "$HOME/Library/Saved Application State/com.accomplish.lite.savedState" + "$HOME/Library/Saved Application State/ai.accomplish.desktop.savedState" + "$HOME/Library/Saved Application State/ai.accomplish.lite.savedState" +) + +for dir in "${SAVED_STATE_DIRS[@]}"; do + if [ -d "$dir" ]; then + rm -rf "$dir" + echo " - Removed: $dir" + fi +done + +# Keychain entries +echo "Clearing keychain entries..." +KEYCHAIN_SERVICES=( + "Accomplish" + "Accomplish Lite" + "com.accomplish.desktop" + "com.accomplish.lite" + "ai.accomplish.desktop" + "ai.accomplish.lite" + "@accomplish/desktop" +) +KEYCHAIN_KEYS=("accessToken" "refreshToken" "userId" "tokenExpiresAt" "tokenIntegrity" "deviceSecret") + +for service in "${KEYCHAIN_SERVICES[@]}"; do + for key in "${KEYCHAIN_KEYS[@]}"; do + if security delete-generic-password -s "$service" -a "$key" 2>/dev/null; then + echo " - Removed keychain: $service/$key" + fi + done +done + +# Also try to delete any remaining keychain items by service name +for service in "${KEYCHAIN_SERVICES[@]}"; do + # Try to delete all items for this service (may need multiple attempts) + for _ in {1..10}; do + if ! security delete-generic-password -s "$service" 2>/dev/null; then + break + fi + echo " - Removed additional keychain item for: $service" + done +done + +# Remove application if requested +if [ "$REMOVE_APP" = true ]; then + echo "Removing application..." + APP_PATHS=( + "/Applications/Accomplish.app" + "/Applications/Accomplish Lite.app" + "$HOME/Applications/Accomplish.app" + "$HOME/Applications/Accomplish Lite.app" + ) + + for app in "${APP_PATHS[@]}"; do + if [ -d "$app" ]; then + rm -rf "$app" + echo " - Removed: $app" + fi + done +fi + +# Clear quarantine attributes if we're keeping the app +if [ "$REMOVE_APP" != true ]; then + echo "Clearing quarantine attributes (if app exists)..." + for app in "/Applications/Accomplish.app" "/Applications/Accomplish Lite.app"; do + if [ -d "$app" ]; then + xattr -rd com.apple.quarantine "$app" 2>/dev/null && echo " - Cleared quarantine: $app" || true + fi + done +fi + +echo "" +echo "=== CLEANUP COMPLETE ===" +echo "" + +if [ "$REMOVE_APP" = true ]; then + echo "All Accomplish data and applications have been removed." + echo "You can reinstall from the DMG file." +else + echo "All Accomplish user data has been cleared." + echo "The app will behave like a fresh installation on next launch." +fi diff --git a/openwork-memos-integration/apps/desktop/e2e/README.md b/openwork-memos-integration/apps/desktop/e2e/README.md new file mode 100644 index 000000000..8ac2403e7 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/README.md @@ -0,0 +1,236 @@ +# E2E Test Infrastructure + +This directory contains the E2E test infrastructure for the Openwork desktop app using Playwright. + +## Structure + +``` +e2e/ +├── fixtures/ # Test fixtures (Electron app launch) +├── pages/ # Page object models +├── specs/ # Test specifications +├── utils/ # Test utilities (screenshots, helpers) +└── test-results/ # Test output (screenshots, videos, traces) +``` + +## Fixtures + +### electron-app.ts + +Provides Electron app launch fixture with E2E configuration: + +- **electronApp**: Launches the Electron app with E2E flags +- **window**: Returns the first window (main app window) + +Environment variables automatically set: +- `E2E_SKIP_AUTH=1` - Skip onboarding flow +- `E2E_MOCK_TASK_EVENTS=1` - Mock task execution events + +## Page Objects + +### HomePage + +Methods for interacting with the home page: +- `title` - Home page title +- `taskInput` - Task input textarea +- `submitButton` - Submit button +- `getExampleCard(index)` - Get example card by index +- `enterTask(text)` - Enter task text +- `submitTask()` - Submit task + +### ExecutionPage + +Methods for interacting with the task execution page: +- `statusBadge` - Status badge +- `cancelButton` - Cancel button +- `thinkingIndicator` - Thinking indicator +- `followUpInput` - Follow-up input +- `stopButton` - Stop button +- `permissionModal` - Permission modal +- `allowButton` - Allow button (in permission modal) +- `denyButton` - Deny button (in permission modal) +- `waitForComplete()` - Wait for task completion + +### SettingsPage + +Methods for interacting with the settings page: +- `title` - Settings page title +- `debugModeToggle` - Debug mode toggle +- `modelSection` - Model section +- `modelSelect` - Model select dropdown +- `apiKeyInput` - API key input +- `addApiKeyButton` - Add API key button +- `navigateToSettings()` - Navigate to settings page +- `toggleDebugMode()` - Toggle debug mode +- `selectModel(modelName)` - Select a model +- `addApiKey(provider, key)` - Add API key + +## Utilities + +### screenshots.ts + +Provides AI-friendly screenshot capture with metadata: + +```typescript +import { captureForAI } from '../utils'; + +await captureForAI( + page, + 'task-execution', + 'running', + [ + 'Task is actively running', + 'Status badge shows "Running"', + 'Cancel button is visible' + ] +); +``` + +The utility creates: +- `{testName}-{stateName}-{timestamp}.png` - Screenshot +- `{testName}-{stateName}-{timestamp}.json` - Metadata (viewport, route, criteria) + +## Usage Example + +```typescript +import { test, expect } from '../fixtures'; +import { HomePage, ExecutionPage } from '../pages'; +import { captureForAI } from '../utils'; + +test('should submit a task and navigate to execution', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + // Enter task + await homePage.enterTask('Create a new file called hello.txt'); + await homePage.submitTask(); + + // Wait for navigation to execution page + await executionPage.statusBadge.waitFor({ state: 'visible' }); + + // Capture screenshot for AI evaluation + await captureForAI( + window, + 'task-submission', + 'execution-started', + ['Task execution page loaded', 'Status badge visible'] + ); + + // Assert + await expect(executionPage.statusBadge).toBeVisible(); +}); +``` + +## Running Tests + +Tests run in Docker by default (both locally and in CI). This ensures consistent behavior and enables concurrent test runs from multiple worktrees. + +### Prerequisites + +- Docker Desktop installed and running + +### Commands + +```bash +# Run all E2E tests (in Docker) +pnpm test:e2e + +# Pre-build Docker image (useful for caching) +pnpm test:e2e:build + +# Clean up Docker resources +pnpm test:e2e:clean + +# View HTML report +pnpm test:e2e:report +``` + +### Native Mode (for debugging) + +Run tests directly without Docker when you need Playwright UI or debugger: + +```bash +# Run natively (Electron windows will pop up) +pnpm test:e2e:native + +# Run with Playwright UI +pnpm test:e2e:native:ui + +# Run in debug mode +pnpm test:e2e:native:debug + +# Run fast tests only +pnpm test:e2e:native:fast + +# Run integration tests only +pnpm test:e2e:native:integration +``` + +## How Docker Testing Works + +1. Docker container runs Ubuntu with Xvfb (X Virtual Framebuffer) +2. Xvfb provides a virtual display at `:99` +3. Electron runs "headfully" inside the container, but the display is virtual +4. Test results are mounted to the host for viewing + +### Concurrent Worktree Testing + +Each worktree can run `pnpm test:e2e` simultaneously because: +- Each container has its own isolated filesystem +- Each container has its own virtual display +- Electron's single-instance lock is per-container, not per-host + +### Troubleshooting + +**Tests fail with "cannot open display"** +- Ensure Xvfb is starting (check Docker logs) +- Verify `DISPLAY=:99` is set + +**Tests fail with sandbox errors** +- The `--no-sandbox` flag is automatically added in Docker +- Ensure `DOCKER_ENV=1` is in the environment + +**Out of memory errors** +- Increase Docker's memory allocation in Docker Desktop settings +- The compose file sets `shm_size: 2gb` for Chromium + +## Writing Tests + +1. Import fixtures and page objects: + ```typescript + import { test, expect } from '../fixtures'; + import { HomePage } from '../pages'; + ``` + +2. Use page objects instead of direct selectors: + ```typescript + // Good + await homePage.submitTask(); + + // Bad + await window.getByTestId('task-input-submit').click(); + ``` + +3. Add test IDs to new UI elements in renderer: + ```tsx + + ``` + +4. Use `captureForAI` for screenshots with evaluation criteria: + ```typescript + await captureForAI( + window, + 'my-test', + 'some-state', + ['Criterion 1', 'Criterion 2'] + ); + ``` + +## Best Practices + +- Use page objects for all UI interactions +- Add descriptive test IDs (`data-testid`) to UI elements +- Use `captureForAI` for important states to enable AI-based evaluation +- Keep tests focused and independent +- Use serial execution (configured in playwright.config.ts) +- Mock task events for fast tests, use real execution for integration tests diff --git a/openwork-memos-integration/apps/desktop/e2e/config/index.ts b/openwork-memos-integration/apps/desktop/e2e/config/index.ts new file mode 100644 index 000000000..79d1a2429 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/config/index.ts @@ -0,0 +1 @@ +export { TEST_TIMEOUTS, TEST_SCENARIOS, type TestScenario } from './timeouts'; diff --git a/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts b/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts new file mode 100644 index 000000000..2c8eab6f1 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts @@ -0,0 +1,62 @@ +/** + * Centralized timeout constants for E2E tests. + * Adjust these based on CI environment performance. + */ +export const TEST_TIMEOUTS = { + /** Time for CSS animations to complete */ + ANIMATION: 300, + + /** Short wait for React state updates */ + STATE_UPDATE: 500, + + /** Time for React hydration after page load */ + HYDRATION: 1500, + + /** Time between app close and next launch (single-instance lock release) */ + APP_RESTART: 1000, + + /** Task completion with mock flow */ + TASK_COMPLETION: 3000, + + /** Navigation between pages */ + NAVIGATION: 5000, + + /** Permission modal appearance */ + PERMISSION_MODAL: 10000, + + /** Wait for task to reach completed/failed/stopped state */ + TASK_COMPLETE_WAIT: 20000, +} as const; + +/** + * Test scenario definitions with explicit keywords. + * Using prefixed keywords to avoid false positives. + */ +export const TEST_SCENARIOS = { + SUCCESS: { + keyword: '__e2e_success__', + description: 'Task completes successfully', + }, + WITH_TOOL: { + keyword: '__e2e_tool__', + description: 'Task uses tools (Read, Grep)', + }, + PERMISSION: { + keyword: '__e2e_permission__', + description: 'Task requires file permission', + }, + ERROR: { + keyword: '__e2e_error__', + description: 'Task fails with error', + }, + INTERRUPTED: { + keyword: '__e2e_interrupt__', + description: 'Task is interrupted by user', + }, + QUESTION: { + keyword: '__e2e_question__', + description: 'Task requires user question/choice', + }, +} as const; + +export type TestScenario = keyof typeof TEST_SCENARIOS; diff --git a/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile b/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile new file mode 100644 index 000000000..cdfdbb54e --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile @@ -0,0 +1,52 @@ +# Base image with Playwright dependencies pre-installed +FROM mcr.microsoft.com/playwright:v1.49.1-noble + +# Install Xvfb, build tools (for node-pty), and additional dependencies for Electron +RUN apt-get update && apt-get install -y \ + xvfb \ + build-essential \ + python3 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2t64 \ + libpango-1.0-0 \ + libcairo2 \ + && rm -rf /var/lib/apt/lists/* + +# Install pnpm +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate + +# Set working directory +WORKDIR /app + +# Copy package files first for better caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY packages/shared/package.json ./packages/shared/ +COPY apps/desktop/package.json ./apps/desktop/ + +# Copy skills directories (needed by postinstall script) +COPY apps/desktop/skills ./apps/desktop/skills + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy source code +COPY . . + +# Build the desktop app +RUN pnpm -F @accomplish/desktop build + +# Set display for Xvfb +ENV DISPLAY=:99 + +# Default command: start Xvfb and run tests (using native Playwright, not Docker) +CMD ["sh", "-c", "Xvfb :99 -screen 0 1920x1080x24 & sleep 1 && pnpm -F @accomplish/desktop test:e2e:native"] diff --git a/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml b/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml new file mode 100644 index 000000000..eb0b6f3f3 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml @@ -0,0 +1,20 @@ +services: + e2e-tests: + build: + context: ../../../.. + dockerfile: apps/desktop/e2e/docker/Dockerfile + environment: + - E2E_SKIP_AUTH=1 + - E2E_MOCK_TASK_EVENTS=1 + - NODE_ENV=test + - DISPLAY=:99 + - DOCKER_ENV=1 + volumes: + # Mount test results for viewing on host + - ../test-results:/app/apps/desktop/e2e/test-results + - ../html-report:/app/apps/desktop/e2e/html-report + # Increase shared memory for Chromium + shm_size: '2gb' + # Allow running privileged for Electron sandbox + security_opt: + - seccomp:unconfined diff --git a/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts b/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts new file mode 100644 index 000000000..3143ac79f --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts @@ -0,0 +1,67 @@ +import { test as base, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; +import { TEST_TIMEOUTS } from '../config'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Custom fixtures for Electron E2E testing. + */ +type ElectronFixtures = { + /** The Electron application instance */ + electronApp: ElectronApplication; + /** The main renderer window (not DevTools) */ + window: Page; +}; + +/** + * Extended Playwright test with Electron fixtures. + * Each test gets a fresh app instance to ensure isolation. + */ +export const test = base.extend({ + electronApp: async ({}, use) => { + const mainPath = resolve(__dirname, '../../dist-electron/main/index.js'); + + const app = await electron.launch({ + args: [ + mainPath, + '--e2e-skip-auth', + '--e2e-mock-tasks', + // Disable sandbox in Docker (required for containerized Electron) + ...(process.env.DOCKER_ENV === '1' ? ['--no-sandbox', '--disable-gpu'] : []), + ], + env: { + ...process.env, + E2E_SKIP_AUTH: '1', + E2E_MOCK_TASK_EVENTS: '1', + NODE_ENV: 'test', + }, + }); + + await use(app); + + // Close app and wait for single-instance lock release + await app.close(); + await new Promise(resolve => setTimeout(resolve, TEST_TIMEOUTS.APP_RESTART)); + }, + + window: async ({ electronApp }, use) => { + // Get the first window - DevTools is disabled in E2E mode + const window = await electronApp.firstWindow(); + + // Wait for page to be fully loaded + await window.waitForLoadState('load'); + + // Wait for React hydration by checking for a core UI element + await window.waitForSelector('[data-testid="task-input-textarea"]', { + state: 'visible', + timeout: TEST_TIMEOUTS.NAVIGATION, + }); + + await use(window); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts b/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts new file mode 100644 index 000000000..e403854a8 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts @@ -0,0 +1 @@ +export { test, expect } from './electron-app'; diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts b/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts new file mode 100644 index 000000000..60152a0f7 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test'; +import { TEST_TIMEOUTS } from '../config'; + +export class ExecutionPage { + constructor(private page: Page) {} + + get statusBadge() { + return this.page.getByTestId('execution-status-badge'); + } + + get cancelButton() { + return this.page.getByTestId('execution-cancel-button'); + } + + get thinkingIndicator() { + return this.page.getByTestId('execution-thinking-indicator'); + } + + get followUpInput() { + return this.page.getByTestId('execution-follow-up-input'); + } + + get stopButton() { + return this.page.getByTestId('execution-stop-button'); + } + + get permissionModal() { + return this.page.getByTestId('execution-permission-modal'); + } + + get allowButton() { + return this.page.getByTestId('permission-allow-button'); + } + + get denyButton() { + return this.page.getByTestId('permission-deny-button'); + } + + /** Get all question option buttons inside the permission modal */ + get questionOptions() { + return this.permissionModal.locator('button').filter({ hasText: /Option|Other/ }); + } + + /** Get the custom response text input (visible when "Other" is selected) */ + get customResponseInput() { + return this.page.getByPlaceholder('Type your response...'); + } + + /** Get the "Back to options" button (visible in custom input mode) */ + get backToOptionsButton() { + return this.page.getByText('← Back to options'); + } + + /** Select a question option by index (0-based) */ + async selectQuestionOption(index: number) { + await this.questionOptions.nth(index).click(); + } + + async waitForComplete() { + // Wait for status badge to show a completed state (not running) + await this.page.waitForFunction( + () => { + const badge = document.querySelector('[data-testid="execution-status-badge"]'); + if (!badge) return false; + const text = badge.textContent?.toLowerCase() || ''; + return text.includes('completed') || text.includes('failed') || text.includes('stopped') || text.includes('cancelled'); + }, + { timeout: TEST_TIMEOUTS.TASK_COMPLETE_WAIT } + ); + } +} diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts b/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts new file mode 100644 index 000000000..2e9ead2b9 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts @@ -0,0 +1,37 @@ +import type { Page } from '@playwright/test'; + +export class HomePage { + constructor(private page: Page) {} + + get title() { + return this.page.getByTestId('home-title'); + } + + get taskInput() { + return this.page.getByTestId('task-input-textarea'); + } + + get submitButton() { + return this.page.getByTestId('task-input-submit'); + } + + get examplesToggle() { + return this.page.getByText('Example prompts'); + } + + getExampleCard(index: number) { + return this.page.getByTestId(`home-example-${index}`); + } + + async expandExamples() { + await this.examplesToggle.click(); + } + + async enterTask(text: string) { + await this.taskInput.fill(text); + } + + async submitTask() { + await this.submitButton.click(); + } +} diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/index.ts b/openwork-memos-integration/apps/desktop/e2e/pages/index.ts new file mode 100644 index 000000000..054baf888 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/pages/index.ts @@ -0,0 +1,3 @@ +export { HomePage } from './home.page'; +export { ExecutionPage } from './execution.page'; +export { SettingsPage } from './settings.page'; diff --git a/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts b/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts new file mode 100644 index 000000000..bb70b2d5b --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts @@ -0,0 +1,247 @@ +import type { Page } from '@playwright/test'; +import { TEST_TIMEOUTS } from '../config'; + +export class SettingsPage { + constructor(private page: Page) {} + + // ===== Provider Grid ===== + + get providerGrid() { + return this.page.getByTestId('provider-grid'); + } + + get providerSearchInput() { + return this.page.getByTestId('provider-search-input'); + } + + get showAllButton() { + return this.page.getByRole('button', { name: 'Show All' }); + } + + get hideButton() { + return this.page.getByRole('button', { name: 'Hide' }); + } + + getProviderCard(providerId: string) { + return this.page.getByTestId(`provider-card-${providerId}`); + } + + getProviderConnectedBadge(providerId: string) { + return this.page.getByTestId(`provider-connected-badge-${providerId}`); + } + + // ===== Connection Status ===== + + get connectionStatus() { + return this.page.getByTestId('connection-status'); + } + + get disconnectButton() { + return this.page.getByTestId('disconnect-button'); + } + + get connectButton() { + return this.page.getByRole('button', { name: 'Connect' }); + } + + // ===== Model Selection ===== + + get modelSelector() { + return this.page.getByTestId('model-selector'); + } + + get modelSelectorError() { + return this.page.getByTestId('model-selector-error'); + } + + // ===== API Key Input ===== + + get apiKeyInput() { + return this.page.getByTestId('api-key-input'); + } + + get apiKeyHelpLink() { + return this.page.getByRole('link', { name: 'How can I find it?' }); + } + + // ===== Bedrock Specific ===== + + get bedrockAccessKeyTab() { + return this.page.getByRole('button', { name: 'Access Key' }); + } + + get bedrockAwsProfileTab() { + return this.page.getByRole('button', { name: 'AWS Profile' }); + } + + get bedrockAccessKeyIdInput() { + return this.page.getByTestId('bedrock-access-key-id'); + } + + get bedrockSecretKeyInput() { + return this.page.getByTestId('bedrock-secret-key'); + } + + get bedrockSessionTokenInput() { + return this.page.getByTestId('bedrock-session-token'); + } + + get bedrockProfileNameInput() { + return this.page.getByTestId('bedrock-profile-name'); + } + + get bedrockRegionSelect() { + return this.page.getByTestId('bedrock-region-select'); + } + + // ===== Ollama Specific ===== + + get ollamaServerUrlInput() { + return this.page.getByTestId('ollama-server-url'); + } + + get ollamaConnectionError() { + return this.page.getByTestId('ollama-connection-error'); + } + + // ===== LiteLLM Specific ===== + + get litellmServerUrlInput() { + return this.page.getByTestId('litellm-server-url'); + } + + get litellmApiKeyInput() { + return this.page.getByTestId('litellm-api-key'); + } + + // ===== OpenRouter Specific ===== + + get openrouterFetchModelsButton() { + return this.page.getByRole('button', { name: /Fetch Models|Refresh/ }); + } + + // ===== Debug Mode ===== + + get debugModeToggle() { + return this.page.getByTestId('settings-debug-toggle'); + } + + // ===== Dialog ===== + + get settingsDialog() { + return this.page.getByTestId('settings-dialog'); + } + + get doneButton() { + return this.page.getByTestId('settings-done-button'); + } + + get closeWarning() { + return this.page.getByText('No provider ready'); + } + + get closeAnywayButton() { + return this.page.getByRole('button', { name: 'Close Anyway' }); + } + + get sidebarSettingsButton() { + return this.page.getByTestId('sidebar-settings-button'); + } + + // ===== Actions ===== + + async navigateToSettings() { + await this.sidebarSettingsButton.click(); + await this.settingsDialog.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }); + } + + async selectProvider(providerId: string) { + await this.getProviderCard(providerId).click(); + // Wait for panel to appear + await this.page.waitForTimeout(300); + } + + async searchProvider(query: string) { + await this.providerSearchInput.fill(query); + } + + async clearSearch() { + await this.providerSearchInput.clear(); + } + + async toggleShowAll() { + const showAllVisible = await this.showAllButton.isVisible(); + if (showAllVisible) { + await this.showAllButton.click(); + } else { + await this.hideButton.click(); + } + } + + async enterApiKey(key: string) { + await this.apiKeyInput.fill(key); + } + + async clickConnect() { + await this.connectButton.click(); + } + + async clickDisconnect() { + await this.disconnectButton.click(); + } + + async selectModel(modelId: string) { + await this.modelSelector.selectOption(modelId); + } + + async toggleDebugMode() { + await this.debugModeToggle.click(); + } + + async closeDialog() { + await this.doneButton.click(); + } + + async pressEscapeToClose() { + await this.page.keyboard.press('Escape'); + } + + // Bedrock specific actions + async selectBedrockAccessKeyTab() { + await this.bedrockAccessKeyTab.click(); + } + + async selectBedrockAwsProfileTab() { + await this.bedrockAwsProfileTab.click(); + } + + async enterBedrockAccessKeyCredentials(accessKeyId: string, secretKey: string, sessionToken?: string) { + await this.bedrockAccessKeyIdInput.fill(accessKeyId); + await this.bedrockSecretKeyInput.fill(secretKey); + if (sessionToken) { + await this.bedrockSessionTokenInput.fill(sessionToken); + } + } + + async enterBedrockProfileCredentials(profileName: string) { + await this.bedrockProfileNameInput.fill(profileName); + } + + async selectBedrockRegion(region: string) { + await this.bedrockRegionSelect.selectOption(region); + } + + // Ollama specific actions + async enterOllamaServerUrl(url: string) { + await this.ollamaServerUrlInput.fill(url); + } + + // LiteLLM specific actions + async enterLiteLLMServerUrl(url: string) { + await this.litellmServerUrlInput.fill(url); + } + + async enterLiteLLMApiKey(key: string) { + await this.litellmApiKeyInput.fill(key); + } +} diff --git a/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts b/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts new file mode 100644 index 000000000..29ea63c26 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './specs', + outputDir: './test-results', + + // Serial execution (Electron single-instance) + workers: 1, + fullyParallel: false, + + // Timeouts + timeout: 60000, + expect: { + timeout: 10000, + toHaveScreenshot: { maxDiffPixels: 100, threshold: 0.2 } + }, + + // Retry on CI + retries: process.env.CI ? 2 : 0, + + // Reporters (paths relative to config file location) + reporter: [ + ['html', { outputFolder: './html-report' }], + ['json', { outputFile: './test-results.json' }], + ['list'] + ], + + use: { + screenshot: 'only-on-failure', + video: 'retain-on-failure', + trace: 'retain-on-failure', + }, + + projects: [ + { + name: 'electron-fast', + testMatch: /.*(home|execution|settings|settings-bedrock)\.spec\.ts/, + timeout: 60000, + }, + { + name: 'electron-integration', + testMatch: /.*integration\.spec\.ts/, + timeout: 120000, + retries: 0, + } + ], +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts b/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts new file mode 100644 index 000000000..91f533081 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts @@ -0,0 +1,618 @@ +import { test, expect } from '../fixtures'; +import { HomePage, ExecutionPage } from '../pages'; +import { captureForAI } from '../utils'; +import { TEST_TIMEOUTS, TEST_SCENARIOS } from '../config'; + +test.describe('Execution Page', () => { + test('should display running state with thinking indicator', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit success keyword + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + await homePage.submitTask(); + + // Wait for navigation to execution page + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for either thinking indicator or status badge to appear + await Promise.race([ + executionPage.thinkingIndicator.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + executionPage.statusBadge.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + ]); + + // Capture running state + await captureForAI( + window, + 'execution-running', + 'thinking-indicator', + [ + 'Execution page is loaded', + 'Thinking indicator is visible', + 'Task is in running state', + 'UI shows active processing' + ] + ); + + // Assert thinking indicator or status badge is visible + // Note: It might complete quickly in mock mode + const thinkingVisible = await executionPage.thinkingIndicator.isVisible(); + const statusVisible = await executionPage.statusBadge.isVisible(); + + // Either thinking indicator or status badge should be visible + expect(thinkingVisible || statusVisible).toBe(true); + }); + + test('should display completed state with success badge', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit success keyword + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for completion + await executionPage.waitForComplete(); + + // Capture completed state + await captureForAI( + window, + 'execution-completed', + 'success-badge', + [ + 'Status badge shows completed state', + 'Task completed successfully', + 'Success indicator is visible', + 'No error messages displayed' + ] + ); + + // Assert status badge is visible + await expect(executionPage.statusBadge).toBeVisible(); + + // Verify it's showing a success/completed state + const badgeText = await executionPage.statusBadge.textContent(); + expect(badgeText?.toLowerCase()).toMatch(/complete|success|done/i); + }); + + test('should display tool usage during execution', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit tool keyword + await homePage.enterTask(TEST_SCENARIOS.WITH_TOOL.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for either thinking indicator or status badge to appear (tool execution started) + await Promise.race([ + executionPage.thinkingIndicator.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + executionPage.statusBadge.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + ]); + + // Capture tool usage state + await captureForAI( + window, + 'execution-tool-usage', + 'tool-display', + [ + 'Tool usage is displayed', + 'Tool name or icon is visible', + 'Tool execution is shown to user', + 'UI clearly indicates tool interaction' + ] + ); + + // Look for tool-related UI elements + const pageContent = await window.textContent('body'); + + // Wait for completion to see full tool usage + await executionPage.waitForComplete(); + + // Capture final state with tools + await captureForAI( + window, + 'execution-tool-usage', + 'tools-complete', + [ + 'Tools were executed during task', + 'Tool results are displayed', + 'Complete history of tool usage visible' + ] + ); + + // Assert page contains tool-related content + expect(pageContent).toBeTruthy(); + }); + + test('should display permission modal with allow/deny buttons', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit permission keyword + await homePage.enterTask(TEST_SCENARIOS.PERMISSION.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for permission modal to appear + await executionPage.permissionModal.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.PERMISSION_MODAL }); + + // Capture permission modal + await captureForAI( + window, + 'execution-permission', + 'modal-visible', + [ + 'Permission modal is displayed', + 'Allow button is visible and clickable', + 'Deny button is visible and clickable', + 'Modal clearly shows what permission is being requested', + 'User can make a choice' + ] + ); + + // Assert permission modal and buttons are visible + await expect(executionPage.permissionModal).toBeVisible(); + await expect(executionPage.allowButton).toBeVisible(); + await expect(executionPage.denyButton).toBeVisible(); + + // Verify buttons are enabled + await expect(executionPage.allowButton).toBeEnabled(); + await expect(executionPage.denyButton).toBeEnabled(); + }); + + test('should handle permission allow action', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit permission keyword + await homePage.enterTask(TEST_SCENARIOS.PERMISSION.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for permission modal and allow button to be ready + await executionPage.permissionModal.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.PERMISSION_MODAL }); + await executionPage.allowButton.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Click allow button + await executionPage.allowButton.click(); + + // Capture state after allowing + await captureForAI( + window, + 'execution-permission', + 'after-allow', + [ + 'Permission modal is dismissed', + 'Task continues execution', + 'Permission was granted successfully' + ] + ); + + // Modal should disappear after clicking allow + await expect(executionPage.permissionModal).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Note: Mock flow doesn't simulate continuation after permission grant, + // so we just verify the modal dismissed (the core allow functionality). + // In real usage, the task would continue after permission is granted. + }); + + test('should handle permission deny action', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit permission keyword + await homePage.enterTask(TEST_SCENARIOS.PERMISSION.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for permission modal and deny button to be ready + await executionPage.permissionModal.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.PERMISSION_MODAL }); + await executionPage.denyButton.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Click deny button + await executionPage.denyButton.click(); + + // Capture state after denying + await captureForAI( + window, + 'execution-permission', + 'after-deny', + [ + 'Permission modal is dismissed', + 'Task handles denied permission gracefully', + 'Appropriate message shown to user' + ] + ); + + // Modal should disappear + await expect(executionPage.permissionModal).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for status badge to show any state after denial (not necessarily completion) + await executionPage.statusBadge.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.PERMISSION_MODAL }); + + // Capture final state after denial + await captureForAI( + window, + 'execution-permission', + 'deny-result', + [ + 'Task responded to permission denial', + 'No crashes or errors', + 'User feedback is clear' + ] + ); + }); + + test('should display error state when task fails', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit error keyword + await homePage.enterTask(TEST_SCENARIOS.ERROR.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for task to complete with error state + await executionPage.waitForComplete(); + + // Capture error state + await captureForAI( + window, + 'execution-error', + 'error-displayed', + [ + 'Error state is clearly visible', + 'Error message or indicator is shown', + 'User understands task failed', + 'Error handling is graceful' + ] + ); + + // Look for error indicators in the UI + const pageContent = await window.textContent('body'); + const statusBadgeVisible = await executionPage.statusBadge.isVisible(); + + // Check if status badge shows error state + if (statusBadgeVisible) { + const badgeText = await executionPage.statusBadge.textContent(); + await captureForAI( + window, + 'execution-error', + 'error-badge', + [ + 'Status badge indicates error/failure', + `Badge shows: ${badgeText}` + ] + ); + } + + // Assert some error indication exists + expect(pageContent).toBeTruthy(); + }); + + test('should display interrupted state when task is stopped', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit interrupt keyword + await homePage.enterTask(TEST_SCENARIOS.INTERRUPTED.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for task to reach interrupted state + await executionPage.waitForComplete(); + + // Capture interrupted state + await captureForAI( + window, + 'execution-interrupted', + 'interrupted-displayed', + [ + 'Interrupted state is visible', + 'Task shows it was stopped', + 'UI clearly indicates interruption', + 'User understands task did not complete normally' + ] + ); + + // Check for interrupted status + const statusBadgeVisible = await executionPage.statusBadge.isVisible(); + + if (statusBadgeVisible) { + const badgeText = await executionPage.statusBadge.textContent(); + await captureForAI( + window, + 'execution-interrupted', + 'interrupted-badge', + [ + 'Status badge shows interrupted/stopped state', + `Badge shows: ${badgeText}` + ] + ); + } + }); + + test('should allow canceling a running task', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit success keyword + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for either cancel or stop button to be available + try { + await Promise.race([ + executionPage.cancelButton.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + executionPage.stopButton.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + ]); + + const cancelVisible = await executionPage.cancelButton.isVisible(); + const stopVisible = await executionPage.stopButton.isVisible(); + + // Capture before cancel + await captureForAI( + window, + 'execution-cancel', + 'before-cancel', + [ + 'Cancel/Stop button is visible', + 'Task is running and can be cancelled' + ] + ); + + // Click the cancel or stop button + if (cancelVisible) { + await executionPage.cancelButton.click(); + } else if (stopVisible) { + await executionPage.stopButton.click(); + } + + // Wait for task to reach cancelled state + await executionPage.waitForComplete(); + + // Capture after cancel + await captureForAI( + window, + 'execution-cancel', + 'after-cancel', + [ + 'Task was cancelled/stopped', + 'UI reflects cancelled state', + 'Cancellation was successful' + ] + ); + } catch { + // Task may have completed before we could cancel - that's acceptable + } + }); + + test('should display task output and messages', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit tool keyword to get more output + await homePage.enterTask(TEST_SCENARIOS.WITH_TOOL.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for task execution to start (either thinking indicator or status badge) + await Promise.race([ + executionPage.thinkingIndicator.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + executionPage.statusBadge.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }), + ]); + + // Capture task output + await captureForAI( + window, + 'execution-output', + 'task-messages', + [ + 'Task output is visible', + 'Messages from task execution are displayed', + 'Output format is clear and readable', + 'User can follow task progress' + ] + ); + + // Wait for completion + await executionPage.waitForComplete(); + + // Capture final output + await captureForAI( + window, + 'execution-output', + 'final-output', + [ + 'Complete task output is visible', + 'All messages and results are displayed', + 'Output is well-formatted' + ] + ); + + // Assert page has content + const pageContent = await window.textContent('body'); + expect(pageContent).toBeTruthy(); + expect(pageContent.length).toBeGreaterThan(0); + }); + + test('should handle follow-up input after task completion', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start and complete a task with explicit success keyword + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + await homePage.submitTask(); + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + await executionPage.waitForComplete(); + + // Wait for follow-up input to be ready (may not appear in all mock scenarios) + try { + await executionPage.followUpInput.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture follow-up input state + await captureForAI( + window, + 'execution-follow-up', + 'follow-up-visible', + [ + 'Follow-up input is visible after task completion', + 'User can enter additional instructions', + 'Follow-up feature is accessible' + ] + ); + + // Try typing in follow-up input + await executionPage.followUpInput.fill('Follow up task'); + + // Capture with follow-up text + await captureForAI( + window, + 'execution-follow-up', + 'follow-up-filled', + [ + 'Follow-up text is entered', + 'Input is ready to submit', + 'User can continue conversation' + ] + ); + + await expect(executionPage.followUpInput).toHaveValue('Follow up task'); + } catch { + // Follow-up input may not appear in all mock scenarios - that's acceptable + } + }); + + test('should display question modal with selectable options', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit question keyword + await homePage.enterTask(TEST_SCENARIOS.QUESTION.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for question modal to appear + await executionPage.permissionModal.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.PERMISSION_MODAL }); + + // Capture question modal + await captureForAI( + window, + 'execution-question', + 'modal-visible', + [ + 'Question modal is displayed', + 'Question text is shown', + 'Option buttons are visible', + 'Submit button is visible but disabled until option selected', + ] + ); + + // Assert modal is visible with options + await expect(executionPage.permissionModal).toBeVisible(); + await expect(executionPage.questionOptions).toHaveCount(3); // Option A, Option B, Other + + // Submit button should be disabled (no option selected yet) + await expect(executionPage.allowButton).toBeDisabled(); + await expect(executionPage.denyButton).toBeVisible(); + }); + + test('should handle question option selection and submit', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Start a task with explicit question keyword + await homePage.enterTask(TEST_SCENARIOS.QUESTION.keyword); + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Wait for question modal to appear + await executionPage.permissionModal.waitFor({ state: 'visible', timeout: TEST_TIMEOUTS.PERMISSION_MODAL }); + + // Select first option (Option A) + await executionPage.selectQuestionOption(0); + + // Capture after selection + await captureForAI( + window, + 'execution-question', + 'option-selected', + [ + 'Option A is selected', + 'Submit button is now enabled', + 'Selected option is highlighted', + ] + ); + + // Submit button should now be enabled + await expect(executionPage.allowButton).toBeEnabled(); + + // Click submit + await executionPage.allowButton.click(); + + // Modal should disappear + await expect(executionPage.permissionModal).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture after submission + await captureForAI( + window, + 'execution-question', + 'after-submit', + [ + 'Question modal is dismissed', + 'Response was submitted successfully', + ] + ); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts b/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts new file mode 100644 index 000000000..19c875d91 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts @@ -0,0 +1,215 @@ +import { test, expect } from '../fixtures'; +import { HomePage } from '../pages'; +import { captureForAI } from '../utils'; +import { TEST_TIMEOUTS, TEST_SCENARIOS } from '../config'; + +test.describe('Home Page', () => { + test('should load home page with title', async ({ window }) => { + const homePage = new HomePage(window); + + // Capture initial home page state + await captureForAI( + window, + 'home-page-load', + 'initial-load', + [ + 'Title "What will you accomplish today?" is visible', + 'Page layout is correct', + 'All UI elements are rendered' + ] + ); + + // Assert title is visible and has correct text + await expect(homePage.title).toBeVisible(); + await expect(homePage.title).toHaveText('What will you accomplish today?'); + }); + + test('should display task input and submit button', async ({ window }) => { + const homePage = new HomePage(window); + + // Capture task input area + await captureForAI( + window, + 'home-page-input', + 'task-input-visible', + [ + 'Task input textarea is visible', + 'Submit button is visible', + 'Input area is ready for user interaction' + ] + ); + + // Assert task input is visible and enabled + await expect(homePage.taskInput).toBeVisible(); + await expect(homePage.submitButton).toBeVisible(); + await expect(homePage.taskInput).toBeEnabled(); + // Submit button is disabled when input is empty (correct behavior) + await expect(homePage.submitButton).toBeDisabled(); + }); + + test('should allow typing in task input', async ({ window }) => { + const homePage = new HomePage(window); + + const testTask = 'Write a hello world program'; + await homePage.enterTask(testTask); + + // Capture filled task input + await captureForAI( + window, + 'home-page-input', + 'task-input-filled', + [ + 'Task input contains typed text', + 'Text is clearly visible', + 'Submit button is enabled with text' + ] + ); + + // Assert input value matches what was typed + await expect(homePage.taskInput).toHaveValue(testTask); + // Button should now be enabled + await expect(homePage.submitButton).toBeEnabled(); + }); + + test('should display example cards', async ({ window }) => { + const homePage = new HomePage(window); + + // Capture example cards (examples are expanded by default) + await captureForAI( + window, + 'home-page-examples', + 'example-cards-visible', + [ + 'At least 3 example cards are visible', + 'Example cards are properly styled', + 'Cards show task examples to users' + ] + ); + + // Assert at least 3 example cards are visible + const exampleCard0 = homePage.getExampleCard(0); + const exampleCard1 = homePage.getExampleCard(1); + const exampleCard2 = homePage.getExampleCard(2); + + await expect(exampleCard0).toBeVisible(); + await expect(exampleCard1).toBeVisible(); + await expect(exampleCard2).toBeVisible(); + }); + + test('should fill input when clicking an example card', async ({ window }) => { + const homePage = new HomePage(window); + + // Click the first example card (examples are expanded by default) + const exampleCard0 = homePage.getExampleCard(0); + await exampleCard0.click(); + + // Wait for input to be filled with example text + await window.waitForFunction( + () => { + const input = document.querySelector('[data-testid="task-input-textarea"]') as HTMLTextAreaElement; + return input && input.value.length > 0; + }, + { timeout: TEST_TIMEOUTS.NAVIGATION } + ); + + // Capture state after clicking example + await captureForAI( + window, + 'home-page-examples', + 'example-card-clicked', + [ + 'Task input is filled with example text', + 'Input value matches the example card content', + 'User can now submit the pre-filled task' + ] + ); + + // Assert input is no longer empty + const inputValue = await homePage.taskInput.inputValue(); + expect(inputValue.length).toBeGreaterThan(0); + }); + + test('should navigate to execution page when submitting a task', async ({ window }) => { + const homePage = new HomePage(window); + + // Enter a task with explicit test keyword + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + + // Wait for button to be enabled + await expect(homePage.submitButton).toBeEnabled(); + + // Capture before submission + await captureForAI( + window, + 'home-page-submit', + 'before-submit', + [ + 'Task is entered in input field', + 'Submit button is ready to click' + ] + ); + + // Submit the task + await homePage.submitTask(); + + // Wait for navigation + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture after navigation + await captureForAI( + window, + 'home-page-submit', + 'after-submit-navigation', + [ + 'URL changed to execution page', + 'Navigation was successful', + 'Execution page is loading' + ] + ); + + // Assert URL changed to execution page + expect(window.url()).toContain('#/execution'); + }); + + test('should handle empty input - submit disabled', async ({ window }) => { + const homePage = new HomePage(window); + + // Capture empty input state + await captureForAI( + window, + 'home-page-validation', + 'empty-input', + [ + 'Task input is empty', + 'Submit button is disabled', + 'User cannot submit an empty task' + ] + ); + + // Submit button should be disabled when input is empty + await expect(homePage.submitButton).toBeDisabled(); + }); + + test('should support multi-line task input', async ({ window }) => { + const homePage = new HomePage(window); + + // Enter a multi-line task + const multiLineTask = 'Line 1\nLine 2\nLine 3'; + await homePage.enterTask(multiLineTask); + + // Capture multi-line input + await captureForAI( + window, + 'home-page-input', + 'multi-line-task', + [ + 'Task input supports multiple lines', + 'All lines are visible in the textarea', + 'Textarea expands to show content' + ] + ); + + // Assert all lines are preserved + await expect(homePage.taskInput).toHaveValue(multiLineTask); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts b/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts new file mode 100644 index 000000000..d52a1836b --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts @@ -0,0 +1,187 @@ +import { test, expect } from '../fixtures'; +import { SettingsPage } from '../pages'; +import { captureForAI } from '../utils'; +import { TEST_TIMEOUTS } from '../config'; + +test.describe('Settings - Amazon Bedrock', () => { + test('should display Bedrock provider card', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + const bedrockCard = settingsPage.getProviderCard('bedrock'); + await expect(bedrockCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'provider-card-visible', + ['Bedrock provider card is visible', 'User can select Bedrock'] + ); + }); + + test('should show Bedrock credential form when selected', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Bedrock provider card + await settingsPage.selectProvider('bedrock'); + + // Verify Access Key tab is visible (default) + await expect(settingsPage.bedrockAccessKeyTab).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + await expect(settingsPage.bedrockAwsProfileTab).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'credential-form-visible', + ['Bedrock credential form is visible', 'Auth tabs are shown'] + ); + }); + + test('should switch between Access Key and AWS Profile tabs', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Bedrock provider card + await settingsPage.selectProvider('bedrock'); + + // Default is Access Key - verify inputs + await expect(settingsPage.bedrockAccessKeyIdInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + await expect(settingsPage.bedrockSecretKeyInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Switch to AWS Profile tab + await settingsPage.selectBedrockAwsProfileTab(); + await expect(settingsPage.bedrockProfileNameInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + await expect(settingsPage.bedrockAccessKeyIdInput).not.toBeVisible(); + + // Switch back to Access Key + await settingsPage.selectBedrockAccessKeyTab(); + await expect(settingsPage.bedrockAccessKeyIdInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'tab-switching', + ['Can switch between auth tabs', 'Form fields update correctly'] + ); + }); + + test('should allow typing in Bedrock access key fields', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Bedrock provider card + await settingsPage.selectProvider('bedrock'); + + const testAccessKey = 'AKIAIOSFODNN7EXAMPLE'; + const testSecretKey = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'; + + await settingsPage.bedrockAccessKeyIdInput.fill(testAccessKey); + await settingsPage.bedrockSecretKeyInput.fill(testSecretKey); + + await expect(settingsPage.bedrockAccessKeyIdInput).toHaveValue(testAccessKey); + await expect(settingsPage.bedrockSecretKeyInput).toHaveValue(testSecretKey); + + // Verify region selector is visible + await expect(settingsPage.bedrockRegionSelect).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'access-key-fields-filled', + ['Access key fields accept input', 'Region selector is available'] + ); + }); + + test('should allow typing in Bedrock profile fields', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Bedrock provider card + await settingsPage.selectProvider('bedrock'); + + // Switch to AWS Profile tab + await settingsPage.selectBedrockAwsProfileTab(); + + const testProfile = 'my-aws-profile'; + + await settingsPage.bedrockProfileNameInput.clear(); + await settingsPage.bedrockProfileNameInput.fill(testProfile); + + await expect(settingsPage.bedrockProfileNameInput).toHaveValue(testProfile); + + // Verify region selector is visible + await expect(settingsPage.bedrockRegionSelect).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'profile-fields-filled', + ['Profile field accepts input', 'Region selector is available'] + ); + }); + + test('should have Connect button for Bedrock credentials', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Bedrock provider card + await settingsPage.selectProvider('bedrock'); + + // Verify Connect button is visible + await expect(settingsPage.connectButton).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'connect-button-visible', + ['Connect button is visible', 'User can connect to Bedrock'] + ); + }); + + test('should display region selector for Bedrock', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Bedrock provider card + await settingsPage.selectProvider('bedrock'); + + // Verify region selector is visible + await expect(settingsPage.bedrockRegionSelect).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'settings-bedrock', + 'region-selector-visible', + ['Region selector is visible', 'User can select AWS region'] + ); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts b/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts new file mode 100644 index 000000000..6dc99006d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts @@ -0,0 +1,351 @@ +import { test, expect } from '../fixtures'; +import { SettingsPage } from '../pages'; +import { captureForAI } from '../utils'; +import { TEST_TIMEOUTS } from '../config'; + +/** + * Comprehensive E2E tests for all provider settings permutations + * + * Provider order (4 columns per row): + * Row 1: Anthropic, OpenAI, Google (Gemini), xAI + * Row 2: DeepSeek, Z-AI, Ollama, Bedrock + * Row 3: OpenRouter, LiteLLM + */ +test.describe('Settings - All Providers', () => { + // ===== GOOGLE (GEMINI) PROVIDER ===== + test.describe('Google (Gemini) Provider', () => { + test('should display Google provider card in first row', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Google is in first 4, should be visible without Show All + const googleCard = settingsPage.getProviderCard('google'); + await expect(googleCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI(window, 'settings-google', 'provider-card-visible', [ + 'Google (Gemini) provider card is visible', + 'Card is in first row (no Show All needed)', + ]); + }); + + test('should show API key form when selecting Google', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('google'); + await expect(settingsPage.apiKeyInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI(window, 'settings-google', 'api-key-form', [ + 'Google API key input is visible', + 'User can enter Gemini API key', + ]); + }); + + test('should allow typing Google API key', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('google'); + const testKey = 'AIzaSyTest_GoogleKey_12345'; + await settingsPage.apiKeyInput.fill(testKey); + + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + await captureForAI(window, 'settings-google', 'api-key-filled', [ + 'Google API key input accepts value', + 'Key format is displayed correctly', + ]); + }); + }); + + // ===== XAI PROVIDER ===== + test.describe('xAI Provider', () => { + test('should display xAI provider card in first row', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // xAI is in first 4, should be visible without Show All + const xaiCard = settingsPage.getProviderCard('xai'); + await expect(xaiCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI(window, 'settings-xai', 'provider-card-visible', [ + 'xAI provider card is visible', + 'Card is in first row (no Show All needed)', + ]); + }); + + test('should show API key form when selecting xAI', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('xai'); + + await expect(settingsPage.apiKeyInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI(window, 'settings-xai', 'api-key-form', [ + 'xAI API key input is visible', + 'User can enter xAI API key', + ]); + }); + + test('should allow typing xAI API key', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('xai'); + + const testKey = 'xai-test-key-67890'; + await settingsPage.apiKeyInput.fill(testKey); + + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + await captureForAI(window, 'settings-xai', 'api-key-filled', [ + 'xAI API key input accepts value', + 'Key format is displayed correctly', + ]); + }); + }); + + // ===== OPENAI PROVIDER ===== + test.describe('OpenAI Provider', () => { + test('should display OpenAI provider card in first row', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // OpenAI is in first 4 + const openaiCard = settingsPage.getProviderCard('openai'); + await expect(openaiCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI(window, 'settings-openai', 'provider-card-visible', [ + 'OpenAI provider card is visible', + 'Card is in first row', + ]); + }); + + test('should show API key form when selecting OpenAI', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('openai'); + await expect(settingsPage.apiKeyInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI(window, 'settings-openai', 'api-key-form', [ + 'OpenAI API key input is visible', + ]); + }); + + test('should allow typing OpenAI API key', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('openai'); + const testKey = 'sk-test-openai-key-12345'; + await settingsPage.apiKeyInput.fill(testKey); + + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + await captureForAI(window, 'settings-openai', 'api-key-filled', [ + 'OpenAI API key input accepts value', + ]); + }); + }); + + // ===== GRID LAYOUT TESTS ===== + test.describe('Provider Grid Layout', () => { + test('should display 4 providers in collapsed view', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // First 4 providers should be visible + await expect(settingsPage.getProviderCard('anthropic')).toBeVisible(); + await expect(settingsPage.getProviderCard('openai')).toBeVisible(); + await expect(settingsPage.getProviderCard('google')).toBeVisible(); + await expect(settingsPage.getProviderCard('xai')).toBeVisible(); + + // 5th provider (deepseek) should NOT be visible in collapsed view + await expect(settingsPage.getProviderCard('deepseek')).not.toBeVisible(); + + await captureForAI(window, 'settings-grid', 'collapsed-view', [ + 'First 4 providers visible in collapsed view', + 'Grid uses 4-column layout', + ]); + }); + + test('should expand to show all 10 providers', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.toggleShowAll(); + + // All 10 providers should be visible + const allProviders = [ + 'anthropic', 'openai', 'google', 'xai', + 'deepseek', 'zai', 'ollama', 'bedrock', + 'openrouter', 'litellm' + ]; + + for (const providerId of allProviders) { + await expect(settingsPage.getProviderCard(providerId)).toBeVisible(); + } + + await captureForAI(window, 'settings-grid', 'expanded-view', [ + 'All 10 providers visible in expanded view', + 'Grid shows 3 rows of providers', + ]); + }); + + test('should toggle between Show All and Hide', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Initial state - Show All button visible + await expect(settingsPage.showAllButton).toBeVisible(); + + // Click Show All + await settingsPage.toggleShowAll(); + await expect(settingsPage.hideButton).toBeVisible(); + + // Click Hide + await settingsPage.toggleShowAll(); + await expect(settingsPage.showAllButton).toBeVisible(); + + // DeepSeek should be hidden again (5th provider) + await expect(settingsPage.getProviderCard('deepseek')).not.toBeVisible(); + + await captureForAI(window, 'settings-grid', 'toggle-behavior', [ + 'Show All/Hide toggle works correctly', + 'Grid collapses back to 4 providers', + ]); + }); + }); + + // ===== PROVIDER SELECTION FLOW ===== + test.describe('Provider Selection Flow', () => { + test('should switch between providers in first row', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Select Anthropic + await settingsPage.selectProvider('anthropic'); + await expect(settingsPage.apiKeyInput).toBeVisible(); + + // Switch to OpenAI + await settingsPage.selectProvider('openai'); + await expect(settingsPage.apiKeyInput).toBeVisible(); + + // Switch to Google + await settingsPage.selectProvider('google'); + await expect(settingsPage.apiKeyInput).toBeVisible(); + + await captureForAI(window, 'settings-selection', 'switch-providers', [ + 'Can switch between providers', + 'Settings panel updates for each provider', + ]); + }); + + test('should switch from classic provider to custom provider', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Select Anthropic (classic API key provider) + await settingsPage.selectProvider('anthropic'); + await expect(settingsPage.apiKeyInput).toBeVisible(); + + // Expand and switch to Ollama (URL-based provider) + await settingsPage.toggleShowAll(); + await settingsPage.selectProvider('ollama'); + await expect(settingsPage.ollamaServerUrlInput).toBeVisible(); + + // API key input should not be visible for Ollama + await expect(settingsPage.apiKeyInput).not.toBeVisible(); + + await captureForAI(window, 'settings-selection', 'switch-provider-types', [ + 'Can switch from API key to URL-based provider', + 'Form updates correctly for different provider types', + ]); + }); + + test('should switch from URL provider back to classic provider', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Expand and select Ollama first + await settingsPage.toggleShowAll(); + await settingsPage.selectProvider('ollama'); + await expect(settingsPage.ollamaServerUrlInput).toBeVisible(); + + // Switch back to Anthropic + await settingsPage.selectProvider('anthropic'); + await expect(settingsPage.apiKeyInput).toBeVisible(); + + // Ollama URL should not be visible + await expect(settingsPage.ollamaServerUrlInput).not.toBeVisible(); + + await captureForAI(window, 'settings-selection', 'switch-back-to-classic', [ + 'Can switch from URL provider back to classic', + 'Form updates correctly', + ]); + }); + }); + + // ===== PROVIDER SETTINGS PANEL ===== + test.describe('Provider Settings Panel', () => { + test('should display provider header with logo and name', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('anthropic'); + + // Verify settings panel is visible + const settingsPanel = window.getByTestId('provider-settings-panel'); + await expect(settingsPanel).toBeVisible(); + + await captureForAI(window, 'settings-panel', 'header-visible', [ + 'Provider settings panel is visible', + 'Header shows provider logo and name', + ]); + }); + + test('should show Connect button when not connected', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('anthropic'); + await expect(settingsPage.connectButton).toBeVisible(); + + await captureForAI(window, 'settings-panel', 'connect-button', [ + 'Connect button is visible for disconnected provider', + ]); + }); + + test('should show help link for API key providers', async ({ window }) => { + const settingsPage = new SettingsPage(window); + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + await settingsPage.selectProvider('anthropic'); + await expect(settingsPage.apiKeyHelpLink).toBeVisible(); + + await captureForAI(window, 'settings-panel', 'help-link', [ + 'Help link "How can I find it?" is visible', + ]); + }); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts b/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts new file mode 100644 index 000000000..46fe5a1bc --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts @@ -0,0 +1,750 @@ +import { test, expect } from '../fixtures'; +import { SettingsPage, HomePage, ExecutionPage } from '../pages'; +import { captureForAI } from '../utils'; +import { TEST_TIMEOUTS, TEST_SCENARIOS } from '../config'; + +test.describe('Settings Dialog', () => { + test('should open settings dialog when clicking settings button', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + // Fixture already handles hydration, just ensure DOM is ready + await window.waitForLoadState('domcontentloaded'); + + // Click the settings button in sidebar + await settingsPage.navigateToSettings(); + + // Capture settings dialog + await captureForAI( + window, + 'settings-dialog', + 'dialog-open', + [ + 'Settings dialog is visible', + 'Dialog contains provider grid', + 'User can interact with settings' + ] + ); + + // Verify dialog opened by checking for provider grid + await expect(settingsPage.providerGrid).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + }); + + test('should display provider grid with cards', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Verify provider grid is visible + await expect(settingsPage.providerGrid).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture provider grid + await captureForAI( + window, + 'settings-dialog', + 'provider-grid', + [ + 'Provider grid is visible', + 'Provider cards are displayed', + 'User can select a provider' + ] + ); + }); + + test('should use 4-column grid layout without horizontal scroll', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Wait for provider grid to be visible + await expect(settingsPage.providerGrid).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Get the settings dialog element + const settingsDialog = window.getByTestId('settings-dialog'); + + // Get the provider grid element + const providerGrid = settingsPage.providerGrid; + + // Check that settings dialog does NOT have horizontal scroll + const dialogOverflowX = await settingsDialog.evaluate((el) => { + const style = window.getComputedStyle(el); + return style.overflowX; + }); + + // Dialog should have auto or hidden overflow-x, not scroll + expect(['auto', 'hidden', 'visible']).toContain(dialogOverflowX); + + // Verify the grid uses 4-column layout (grid-cols-4) + const gridContainer = providerGrid.locator('.grid.grid-cols-4').first(); + await expect(gridContainer).toBeVisible(); + + // In collapsed view, first 4 providers should be visible + await expect(settingsPage.getProviderCard('anthropic')).toBeVisible(); + await expect(settingsPage.getProviderCard('openai')).toBeVisible(); + await expect(settingsPage.getProviderCard('google')).toBeVisible(); + await expect(settingsPage.getProviderCard('bedrock')).toBeVisible(); + + // 5th provider should NOT be visible in collapsed view + await expect(settingsPage.getProviderCard('deepseek')).not.toBeVisible(); + + // Capture for verification + await captureForAI( + window, + 'settings-dialog', + 'grid-layout', + [ + 'Settings dialog uses 4-column grid layout', + 'First 4 providers visible in collapsed view', + 'No horizontal scroll needed' + ] + ); + }); + + test('should display API key input when selecting a classic provider', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Select Anthropic provider (a classic provider requiring API key) + await settingsPage.selectProvider('anthropic'); + + // Scroll to API key section if needed + await settingsPage.apiKeyInput.scrollIntoViewIfNeeded(); + + // Verify API key input is visible + await expect(settingsPage.apiKeyInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture API key section + await captureForAI( + window, + 'settings-dialog', + 'api-key-section', + [ + 'API key input is visible', + 'User can enter an API key', + 'Input is accessible' + ] + ); + }); + + test('should allow typing in API key input', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Select Anthropic provider + await settingsPage.selectProvider('anthropic'); + + // Scroll to API key input + await settingsPage.apiKeyInput.scrollIntoViewIfNeeded(); + + // Type in API key input + const testKey = 'sk-ant-test-key-12345'; + await settingsPage.apiKeyInput.fill(testKey); + + // Verify value was entered + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + // Capture filled state + await captureForAI( + window, + 'settings-dialog', + 'api-key-filled', + [ + 'API key input has value', + 'Input accepts text entry', + 'Value is correctly displayed' + ] + ); + }); + + test('should display debug mode toggle', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Debug toggle only shows when a provider is selected - select one first + await settingsPage.getProviderCard('anthropic').click(); + + // Scroll to debug toggle + await settingsPage.debugModeToggle.scrollIntoViewIfNeeded(); + + // Verify debug toggle is visible + await expect(settingsPage.debugModeToggle).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture debug section + await captureForAI( + window, + 'settings-dialog', + 'debug-section', + [ + 'Debug mode toggle is visible', + 'Toggle is clickable', + 'Developer settings are accessible' + ] + ); + }); + + test('should allow toggling debug mode', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Debug toggle only shows when a provider is selected - select one first + await settingsPage.getProviderCard('anthropic').click(); + + // Scroll to debug toggle + await settingsPage.debugModeToggle.scrollIntoViewIfNeeded(); + + // Capture initial state + await captureForAI( + window, + 'settings-dialog', + 'debug-before-toggle', + [ + 'Debug toggle in initial state', + 'Toggle is ready to click' + ] + ); + + // Click toggle - state change is immediate in React + await settingsPage.toggleDebugMode(); + + // Capture toggled state + await captureForAI( + window, + 'settings-dialog', + 'debug-after-toggle', + [ + 'Debug toggle state changed', + 'UI reflects new state' + ] + ); + }); + + test('should close dialog when pressing Escape', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Verify dialog is open + await expect(settingsPage.providerGrid).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Press Escape to close dialog + await window.keyboard.press('Escape'); + + // Dialog might show warning if no provider is ready, click Close Anyway if visible + const closeAnywayVisible = await settingsPage.closeAnywayButton.isVisible().catch(() => false); + if (closeAnywayVisible) { + await settingsPage.closeAnywayButton.click(); + } + + // Verify dialog closed (provider grid should not be visible) + await expect(settingsPage.providerGrid).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture closed state + await captureForAI( + window, + 'settings-dialog', + 'dialog-closed', + [ + 'Dialog is closed', + 'Main app is visible again', + 'Settings are no longer shown' + ] + ); + }); + + test('should display DeepSeek provider card', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Verify DeepSeek provider card is visible + const deepseekCard = settingsPage.getProviderCard('deepseek'); + await expect(deepseekCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture provider selection area + await captureForAI( + window, + 'settings-dialog', + 'deepseek-provider-visible', + [ + 'DeepSeek provider card is visible in settings', + 'Provider card can be clicked', + 'User can select DeepSeek as their provider' + ] + ); + }); + + test('should allow selecting DeepSeek provider and entering API key', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click DeepSeek provider + await settingsPage.selectProvider('deepseek'); + + // Enter API key + const testKey = 'sk-deepseek-test-key-12345'; + await settingsPage.apiKeyInput.fill(testKey); + + // Verify value was entered + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + // Capture filled state + await captureForAI( + window, + 'settings-dialog', + 'deepseek-api-key-filled', + [ + 'DeepSeek provider is selected', + 'API key input accepts DeepSeek key format', + 'Value is correctly displayed' + ] + ); + }); + + test('should display Z.AI provider card', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Verify Z.AI provider card is visible + const zaiCard = settingsPage.getProviderCard('zai'); + await expect(zaiCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture provider selection area + await captureForAI( + window, + 'settings-dialog', + 'zai-provider-visible', + [ + 'Z.AI provider card is visible in settings', + 'Provider card can be clicked', + 'User can select Z.AI as their provider' + ] + ); + }); + + test('should allow selecting Z.AI provider and entering API key', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Z.AI provider + await settingsPage.selectProvider('zai'); + + // Enter API key + const testKey = 'zai-test-api-key-67890'; + await settingsPage.apiKeyInput.fill(testKey); + + // Verify value was entered + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + // Capture filled state + await captureForAI( + window, + 'settings-dialog', + 'zai-api-key-filled', + [ + 'Z.AI provider is selected', + 'API key input accepts Z.AI key format', + 'Value is correctly displayed' + ] + ); + }); + + test('should display all provider cards when Show All is clicked', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Verify provider cards are visible (using provider IDs) + const providerIds = ['anthropic', 'openai', 'openrouter', 'google', 'xai', 'deepseek', 'zai', 'bedrock', 'ollama', 'litellm']; + + for (const providerId of providerIds) { + const card = settingsPage.getProviderCard(providerId); + await expect(card).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + } + + // Capture all providers + await captureForAI( + window, + 'settings-dialog', + 'all-providers-visible', + [ + 'All provider cards are visible', + 'Provider grid shows complete selection', + 'User can select any provider' + ] + ); + }); + + test('should display OpenRouter provider card', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers (OpenRouter is not in first 6) + await settingsPage.toggleShowAll(); + + // Verify OpenRouter provider card is visible + const openrouterCard = settingsPage.getProviderCard('openrouter'); + await expect(openrouterCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture provider selection area + await captureForAI( + window, + 'settings-dialog', + 'openrouter-provider-visible', + [ + 'OpenRouter provider card is visible in settings', + 'Provider card can be clicked', + 'User can select OpenRouter as their provider' + ] + ); + }); + + test('should allow selecting OpenRouter provider and entering API key', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers (OpenRouter is not in first 6) + await settingsPage.toggleShowAll(); + + // Click OpenRouter provider + await settingsPage.selectProvider('openrouter'); + + // Enter API key + const testKey = 'sk-or-v1-test-key-12345'; + await settingsPage.apiKeyInput.fill(testKey); + + // Verify value was entered + await expect(settingsPage.apiKeyInput).toHaveValue(testKey); + + // Capture filled state + await captureForAI( + window, + 'settings-dialog', + 'openrouter-api-key-filled', + [ + 'OpenRouter provider is selected', + 'API key input accepts OpenRouter key format', + 'Value is correctly displayed' + ] + ); + }); + + test('should show LiteLLM provider card and settings', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click LiteLLM provider + await settingsPage.selectProvider('litellm'); + + // Verify LiteLLM server URL input is visible + await expect(settingsPage.litellmServerUrlInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture LiteLLM settings + await captureForAI( + window, + 'settings-dialog', + 'litellm-settings', + [ + 'LiteLLM provider is selected', + 'Server URL input is visible', + 'User can configure LiteLLM connection' + ] + ); + }); + + test('should show Ollama provider card and settings', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Click Ollama provider + await settingsPage.selectProvider('ollama'); + + // Verify Ollama server URL input is visible + await expect(settingsPage.ollamaServerUrlInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture Ollama settings + await captureForAI( + window, + 'settings-dialog', + 'ollama-settings', + [ + 'Ollama provider is selected', + 'Server URL input is visible', + 'User can configure Ollama connection' + ] + ); + }); + + test('should filter providers with search', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All first + await settingsPage.toggleShowAll(); + + // Search for "anthropic" + await settingsPage.searchProvider('anthropic'); + + // Anthropic should be visible + await expect(settingsPage.getProviderCard('anthropic')).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Other providers should not be visible + await expect(settingsPage.getProviderCard('openai')).not.toBeVisible(); + + // Capture filtered state + await captureForAI( + window, + 'settings-dialog', + 'provider-search', + [ + 'Search filters provider cards', + 'Only matching providers visible', + 'Search functionality works' + ] + ); + + // Clear search + await settingsPage.clearSearch(); + + // All providers should be visible again + await expect(settingsPage.getProviderCard('openai')).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + }); + + /** + * Regression test for: "Maximum update depth exceeded" infinite loop bug + * + * Bug: Execution.tsx called getAccomplish() on every render, creating a new + * object reference. This was used as a useEffect dependency, causing: + * render -> new accomplish -> useEffect runs -> setState -> render -> loop + * + * This test verifies Settings dialog opens correctly after a task completes. + */ + test('should open settings dialog after task completes without crashing', async ({ window }) => { + const homePage = new HomePage(window); + const executionPage = new ExecutionPage(window); + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Step 1: Start a task + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + await homePage.submitTask(); + + // Step 2: Wait for navigation to execution page + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Step 3: Wait for task to complete + await executionPage.waitForComplete(); + + // Verify task completed + await expect(executionPage.statusBadge).toBeVisible(); + + // Step 4: Open settings dialog - this is where the bug would cause infinite loop + // The test should NOT timeout here. If it does, the infinite loop bug is present. + await settingsPage.navigateToSettings(); + + // Step 5: Verify settings dialog opened successfully (no crash/freeze) + await expect(settingsPage.providerGrid).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Additional verification: can interact with the dialog + const dialogTitle = window.getByRole('heading', { name: 'Set up Openwork' }); + await expect(dialogTitle).toBeVisible(); + + // Capture successful state + await captureForAI( + window, + 'settings-dialog', + 'after-task-completion', + [ + 'Settings dialog opened successfully after task completion', + 'No infinite loop or crash occurred', + 'Dialog is fully functional' + ] + ); + }); + + /** + * Bug test: Green background should only show on active+ready provider + * + * Bug: Both isActive and isSelected were getting the same green background. + * Expected: Green background should ONLY show on the active provider that is + * connected AND has a model selected (isProviderReady). When clicking another + * provider to view its settings, it should NOT get the green background. + * + * In the E2E test environment, no provider is connected/ready, so we test that + * clicking to select a provider does NOT give it the green background. + */ + test('should only show green background on active ready provider, not on selected provider', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers including Z-AI + await settingsPage.toggleShowAll(); + + // Define color constants + const GREEN_BACKGROUND = 'rgb(233, 247, 231)'; // #e9f7e7 - for active+ready providers only + const DEFAULT_BACKGROUND = 'rgb(249, 248, 246)'; // #f9f8f6 - for unselected providers + + // Get the Anthropic card + const anthropicCard = settingsPage.getProviderCard('anthropic'); + await expect(anthropicCard).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // In E2E test environment, no provider is active+ready, so Anthropic should have default bg + const anthropicBgBefore = await anthropicCard.evaluate((el) => { + return window.getComputedStyle(el).backgroundColor; + }); + expect(anthropicBgBefore).toBe(DEFAULT_BACKGROUND); + + // Get the Z-AI card + const zaiCard = settingsPage.getProviderCard('zai'); + await expect(zaiCard).toBeVisible(); + + // Verify Z-AI has the default background before clicking + const zaiBgBefore = await zaiCard.evaluate((el) => { + return window.getComputedStyle(el).backgroundColor; + }); + expect(zaiBgBefore).toBe(DEFAULT_BACKGROUND); + + // Click on Z-AI to select it (but it's not connected/ready) + await settingsPage.selectProvider('zai'); + + // BUG TEST: Z-AI should NOT have the green background after being selected + // The bug was that isSelected triggered the green background, which is incorrect. + // Green background should ONLY appear for active+ready providers (isActive && isProviderReady). + // A selected-but-not-ready provider should only get a selection border, not green background. + const zaiBgAfter = await zaiCard.evaluate((el) => { + return window.getComputedStyle(el).backgroundColor; + }); + + // This assertion will FAIL if the bug exists (zai gets green background when selected) + // and PASS once the bug is fixed (zai keeps default background when selected) + expect(zaiBgAfter).toBe(DEFAULT_BACKGROUND); + + // Capture for verification + await captureForAI( + window, + 'settings-dialog', + 'green-background-bug-test', + [ + 'Selected but non-ready provider does not have green background', + 'Bug is fixed - isSelected does not trigger green background', + 'Only active+ready providers should have green background' + ] + ); + }); + + test('should enable debug mode and show debug panel on execution page', async ({ window }) => { + const homePage = new HomePage(window); + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Step 1: Open settings and toggle debug mode + await settingsPage.navigateToSettings(); + + // Debug toggle only shows when a provider is selected - select one first + await settingsPage.getProviderCard('anthropic').click(); + await expect(settingsPage.debugModeToggle).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + const toggleButton = settingsPage.debugModeToggle; + + // Check current state of toggle and ensure it's ON for the test + const initialBgClass = await toggleButton.getAttribute('class'); + const isInitiallyOff = initialBgClass?.includes('bg-muted'); + + if (isInitiallyOff) { + // Click to enable debug mode + await settingsPage.toggleDebugMode(); + } + + // Verify toggle is now in ON state + await expect(toggleButton).toHaveClass(/bg-primary/); + + // Verify warning message appears when debug is enabled + const warningMessage = window.getByText('Debug mode is enabled'); + await expect(warningMessage).toBeVisible(); + + // Step 2: Close settings (force close since no provider is set up) + await settingsPage.pressEscapeToClose(); + // If warning appears, click Close Anyway + const closeAnyway = settingsPage.closeAnywayButton; + if (await closeAnyway.isVisible({ timeout: 1000 }).catch(() => false)) { + await closeAnyway.click(); + } + + // Step 3: Start a task + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + await homePage.submitTask(); + + // Step 4: Wait for navigation to execution page + await window.waitForURL(/.*#\/execution.*/, { timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Step 5: Verify debug panel is visible on execution page + // This is the key assertion - debug mode toggle in settings should affect execution page + const debugPanel = window.getByTestId('debug-panel'); + await expect(debugPanel).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Capture the debug panel + await captureForAI( + window, + 'execution-page', + 'debug-panel-enabled', + [ + 'Debug panel is visible at bottom of execution page', + 'Debug mode was successfully enabled in settings', + 'Panel shows Debug Logs header' + ] + ); + }); + +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts b/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts new file mode 100644 index 000000000..b7714f138 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts @@ -0,0 +1,303 @@ +import { test, expect } from '../fixtures'; +import { SettingsPage, HomePage } from '../pages'; +import { captureForAI } from '../utils'; +import { TEST_TIMEOUTS, TEST_SCENARIOS } from '../config'; + +/** + * Tests for the task launch guard functionality. + * + * The task launch guard prevents users from: + * 1. Starting a task without a ready provider (connected + model selected) + * 2. Closing the settings dialog without configuring a provider + */ +test.describe('Task Launch Guard', () => { + test('should display provider grid when opening settings', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Verify provider grid is visible + await expect(settingsPage.providerGrid).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Verify at least some provider cards are visible + await expect(settingsPage.getProviderCard('anthropic')).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + await expect(settingsPage.getProviderCard('openai')).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'task-launch-guard', + 'provider-grid-visible', + [ + 'Provider grid is displayed', + 'Provider cards are visible', + 'User can select a provider' + ] + ); + }); + + test('should show provider settings panel when selecting a provider', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Select Anthropic provider + await settingsPage.selectProvider('anthropic'); + + // Verify the settings panel for the provider is visible + const settingsPanel = window.getByTestId('provider-settings-panel'); + await expect(settingsPanel).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Verify API key input is shown + await expect(settingsPage.apiKeyInput).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'task-launch-guard', + 'provider-settings-panel', + [ + 'Provider settings panel is visible', + 'API key input is shown', + 'User can configure the provider' + ] + ); + }); + + test('should have Done button in settings dialog', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Verify Done button is visible + await expect(settingsPage.doneButton).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'task-launch-guard', + 'done-button-visible', + [ + 'Done button is visible in settings', + 'User can close settings dialog' + ] + ); + }); + + test('should display Close Anyway button when close warning appears', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Try to close with Done button + await settingsPage.doneButton.click(); + + // Check if warning or dialog close occurred + const closeAnywayVisible = await settingsPage.closeAnywayButton.isVisible().catch(() => false); + const dialogClosed = !(await settingsPage.settingsDialog.isVisible().catch(() => true)); + + if (closeAnywayVisible) { + // Warning appeared - verify Close Anyway button + await expect(settingsPage.closeAnywayButton).toBeVisible(); + + await captureForAI( + window, + 'task-launch-guard', + 'close-warning-visible', + [ + 'Close warning is displayed', + 'Close Anyway button is visible', + 'User is warned about missing provider' + ] + ); + } else if (dialogClosed) { + // Dialog closed - a provider must be ready (E2E mode may pre-configure one) + await captureForAI( + window, + 'task-launch-guard', + 'dialog-closed-with-provider', + [ + 'Dialog closed successfully', + 'A provider was ready (E2E mode pre-configured)', + 'Task submission should work' + ] + ); + } + }); + + test('should allow closing dialog with Close Anyway if warning appears', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Try to close with Escape + await window.keyboard.press('Escape'); + + // If warning appears, click Close Anyway + const closeAnywayVisible = await settingsPage.closeAnywayButton.isVisible().catch(() => false); + + if (closeAnywayVisible) { + await settingsPage.closeAnywayButton.click(); + + // Verify dialog closed + await expect(settingsPage.settingsDialog).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + await captureForAI( + window, + 'task-launch-guard', + 'close-anyway-clicked', + [ + 'Close Anyway button was clicked', + 'Dialog closed despite warning', + 'User can proceed without provider' + ] + ); + } else { + // Dialog closed directly - provider was ready + await expect(settingsPage.providerGrid).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + } + }); + + test('should show all providers when Show All is clicked', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Click Show All to see all providers + await settingsPage.toggleShowAll(); + + // Verify all provider cards are visible + const providerIds = ['anthropic', 'openai', 'openrouter', 'google', 'xai', 'deepseek', 'zai', 'bedrock', 'ollama', 'litellm']; + + for (const providerId of providerIds) { + await expect(settingsPage.getProviderCard(providerId)).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + } + + await captureForAI( + window, + 'task-launch-guard', + 'all-providers-visible', + [ + 'All 10 provider cards are visible', + 'Show All expanded the grid', + 'User can select any provider' + ] + ); + }); + + test('should filter providers by search', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // First show all providers + await settingsPage.toggleShowAll(); + + // Search for specific provider + await settingsPage.searchProvider('ollama'); + + // Ollama should be visible + await expect(settingsPage.getProviderCard('ollama')).toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Other providers should not be visible + await expect(settingsPage.getProviderCard('anthropic')).not.toBeVisible(); + await expect(settingsPage.getProviderCard('openai')).not.toBeVisible(); + + await captureForAI( + window, + 'task-launch-guard', + 'search-filters-providers', + [ + 'Search filters provider grid', + 'Only matching provider is visible', + 'Search functionality works correctly' + ] + ); + }); + + test('should be able to navigate back to home and submit task', async ({ window }) => { + const homePage = new HomePage(window); + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + + // Open and close settings + await settingsPage.navigateToSettings(); + await window.keyboard.press('Escape'); + + // Handle close warning if it appears + const closeAnywayVisible = await settingsPage.closeAnywayButton.isVisible().catch(() => false); + if (closeAnywayVisible) { + await settingsPage.closeAnywayButton.click(); + } + + // Wait for dialog to close + await expect(settingsPage.settingsDialog).not.toBeVisible({ timeout: TEST_TIMEOUTS.NAVIGATION }); + + // Enter a task + await homePage.enterTask(TEST_SCENARIOS.SUCCESS.keyword); + + // Submit button should be enabled + await expect(homePage.submitButton).toBeEnabled(); + + await captureForAI( + window, + 'task-launch-guard', + 'ready-to-submit-task', + [ + 'Settings dialog closed', + 'Task input is ready', + 'Submit button is enabled' + ] + ); + }); + + test('should display connected badge on provider card when connected', async ({ window }) => { + const settingsPage = new SettingsPage(window); + + await window.waitForLoadState('domcontentloaded'); + await settingsPage.navigateToSettings(); + + // Check if any provider has a connected badge + // In E2E mode with skip auth, a provider might be pre-configured + const providers = ['anthropic', 'openai', 'openrouter', 'google', 'xai']; + + let foundConnected = false; + for (const providerId of providers) { + const badge = settingsPage.getProviderConnectedBadge(providerId); + const isVisible = await badge.isVisible().catch(() => false); + if (isVisible) { + foundConnected = true; + await captureForAI( + window, + 'task-launch-guard', + 'connected-badge-visible', + [ + `${providerId} provider has connected badge`, + 'Badge indicates provider is configured', + 'User can see which providers are ready' + ] + ); + break; + } + } + + if (!foundConnected) { + // No connected badge - this is expected in fresh state + await captureForAI( + window, + 'task-launch-guard', + 'no-connected-badge', + [ + 'No provider has connected badge', + 'User needs to configure a provider', + 'Provider grid shows available options' + ] + ); + } + }); +}); diff --git a/openwork-memos-integration/apps/desktop/e2e/utils/index.ts b/openwork-memos-integration/apps/desktop/e2e/utils/index.ts new file mode 100644 index 000000000..3686ce8b4 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/utils/index.ts @@ -0,0 +1 @@ +export { captureForAI, type ScreenshotMetadata } from './screenshots'; diff --git a/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts b/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts new file mode 100644 index 000000000..00ec0b573 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts @@ -0,0 +1,109 @@ +/** + * Screenshot utilities for AI-powered visual testing. + * Captures screenshots with metadata for automated evaluation. + */ +import type { Page } from '@playwright/test'; +import * as fs from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ============================================================================ +// Types +// ============================================================================ + +export interface ScreenshotMetadata { + testName: string; + stateName: string; + viewport: { width: number; height: number }; + route: string; + timestamp: string; + evaluationCriteria: string[]; +} + +export interface CaptureResult { + success: boolean; + path: string; + error?: string; +} + +// ============================================================================ +// Screenshot Capture +// ============================================================================ + +/** + * Capture a screenshot with metadata for AI evaluation. + * Includes error handling to prevent test failures from screenshot issues. + * + * @param page - Playwright page to capture + * @param testName - Name of the test (used in filename) + * @param stateName - Description of the UI state (used in filename) + * @param evaluationCriteria - List of criteria for AI evaluation + * @returns Capture result with success status and path + */ +export async function captureForAI( + page: Page, + testName: string, + stateName: string, + evaluationCriteria: string[] +): Promise { + const timestamp = Date.now(); + const sanitizedTestName = sanitizeFilename(testName); + const sanitizedStateName = sanitizeFilename(stateName); + const filename = `${sanitizedTestName}-${sanitizedStateName}-${timestamp}.png`; + const screenshotDir = join(__dirname, '../test-results/screenshots'); + const screenshotPath = join(screenshotDir, filename); + + try { + // Ensure directory exists + await fs.mkdir(screenshotDir, { recursive: true }); + + // Capture screenshot with animations disabled for consistency + await page.screenshot({ + path: screenshotPath, + fullPage: true, + animations: 'disabled', + }); + + // Save metadata alongside screenshot + const viewport = page.viewportSize() || { width: 1280, height: 720 }; + const metadata: ScreenshotMetadata = { + testName, + stateName, + viewport, + route: page.url(), + timestamp: new Date().toISOString(), + evaluationCriteria, + }; + + await fs.writeFile( + screenshotPath.replace('.png', '.json'), + JSON.stringify(metadata, null, 2) + ); + + return { success: true, path: screenshotPath }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn(`[Screenshot] Failed to capture "${testName}/${stateName}": ${errorMessage}`); + return { success: false, path: '', error: errorMessage }; + } +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Sanitize a string for use in filenames. + * Removes or replaces characters that are problematic in file paths. + */ +function sanitizeFilename(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9-_]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 50); +} diff --git a/openwork-memos-integration/apps/desktop/index.html b/openwork-memos-integration/apps/desktop/index.html new file mode 100644 index 000000000..4a0055ba4 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/index.html @@ -0,0 +1,21 @@ + + + + + + + Openwork + + + + + +
+ + + diff --git a/openwork-memos-integration/apps/desktop/package.json b/openwork-memos-integration/apps/desktop/package.json new file mode 100644 index 000000000..17680c922 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/package.json @@ -0,0 +1,178 @@ +{ + "name": "@accomplish/desktop", + "version": "0.2.3", + "private": true, + "type": "module", + "description": "Accomplish Desktop App", + "main": "dist-electron/main/index.js", + "scripts": { + "postinstall": "electron-rebuild && npm --prefix skills/dev-browser install && npm --prefix skills/file-permission install && npm --prefix skills/ask-user-question install", + "dev": "node scripts/patch-electron-name.cjs && rm -rf dist-electron && vite", + "dev:clean": "CLEAN_START=1 vite", + "build": "tsc && vite build && npm --prefix skills/dev-browser install --omit=dev && npm --prefix skills/file-permission install --omit=dev && npm --prefix skills/ask-user-question install --omit=dev", + "build:electron": "tsc && vite build && node scripts/package.cjs", + "build:unpack": "tsc && vite build && node scripts/package.cjs --dir", + "package": "pnpm build && node scripts/package.cjs --mac --publish never", + "package:mac": "pnpm build && node scripts/package.cjs --mac --publish never", + "release": "pnpm build && node scripts/package.cjs --mac --publish always", + "release:mac": "pnpm build && node scripts/package.cjs --mac --publish always", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", + "clean": "rm -rf dist dist-electron release", + "download:nodejs": "node scripts/download-nodejs.cjs", + "test": "vitest run", + "test:unit": "vitest run --config vitest.unit.config.ts", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest watch", + "test:e2e": "docker compose -f e2e/docker/docker-compose.yml up --build --abort-on-container-exit --exit-code-from e2e-tests", + "test:e2e:build": "docker compose -f e2e/docker/docker-compose.yml build", + "test:e2e:clean": "docker compose -f e2e/docker/docker-compose.yml down --rmi local -v", + "test:e2e:report": "playwright show-report e2e/html-report", + "test:e2e:native": "playwright test --config=e2e/playwright.config.ts", + "test:e2e:native:ui": "playwright test --config=e2e/playwright.config.ts --ui", + "test:e2e:native:debug": "playwright test --config=e2e/playwright.config.ts --debug", + "test:e2e:native:fast": "playwright test --config=e2e/playwright.config.ts --project=electron-fast", + "test:e2e:native:integration": "playwright test --config=e2e/playwright.config.ts --project=electron-integration" + }, + "dependencies": { + "@accomplish/shared": "workspace:*", + "@aws-sdk/client-bedrock": "^3.971.0", + "@aws-sdk/credential-providers": "^3.971.0", + "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dotenv": "^17.2.3", + "electron-store": "^8.2.0", + "framer-motion": "^12.26.2", + "lucide-react": "^0.454.0", + "node-pty": "^1.1.0", + "opencode-ai": "1.1.16", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^9.0.1", + "react-router-dom": "^7.1.1", + "tailwind-merge": "^3.3.1", + "zod": "^3.24.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@electron/rebuild": "^4.0.2", + "@playwright/test": "^1.57.0", + "@tailwindcss/typography": "^0.5.15", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "^16.3.1", + "@types/node": "^22.10.2", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.0.17", + "autoprefixer": "^10.4.20", + "electron": "^35.2.1", + "electron-builder": "^25.1.8", + "happy-dom": "^20.1.0", + "jsdom": "^27.4.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2", + "vite": "^6.0.6", + "vite-plugin-electron": "^0.28.8", + "vitest": "^4.0.17" + }, + "build": { + "appId": "ai.accomplish.desktop", + "productName": "Openwork", + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "directories": { + "output": "release", + "buildResources": "resources" + }, + "files": [ + "dist/**/*", + "dist-electron/**/*", + "node_modules/opencode-ai/**", + "node_modules/node-pty/**", + "node_modules/electron-store/**", + "node_modules/conf/**", + "node_modules/env-paths/**", + "node_modules/json-schema-typed/**", + "node_modules/atomically/**", + "node_modules/debounce-fn/**", + "!node_modules/@accomplish/**", + "!node_modules/opencode-darwin-*/**", + "!node_modules/opencode-linux-*/**", + "!node_modules/opencode-win32-*/**" + ], + "asar": true, + "asarUnpack": [ + "node_modules/opencode-ai/bin/opencode", + "node_modules/opencode-ai/package.json", + "node_modules/node-pty/build/**/*.node", + "node_modules/node-pty/package.json", + "dist-electron/main/mcp/*.js" + ], + "afterPack": "./scripts/after-pack.cjs", + "extraResources": [ + { + "from": "resources/icon.png", + "to": "icon.png" + }, + { + "from": "skills", + "to": "skills", + "filter": [ + "**/*", + "!**/profiles/**", + "!**/tmp/**", + "!**/.git/**", + "!**/.browser-data/**", + "!**/bun.lock", + "!**/*.test.ts", + "!**/vitest.config.ts" + ] + } + ], + "publish": { + "provider": "github", + "owner": "accomplish-ai", + "repo": "openwork" + }, + "mac": { + "category": "public.app-category.productivity", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "resources/entitlements.mac.plist", + "entitlementsInherit": "resources/entitlements.mac.plist", + "icon": "resources/icon.png", + "target": [ + "dmg", + "zip" + ] + }, + "dmg": { + "contents": [ + { + "x": 130, + "y": 220 + }, + { + "x": 410, + "y": 220, + "type": "link", + "path": "/Applications" + } + ] + } + } +} diff --git a/openwork-memos-integration/apps/desktop/postcss.config.js b/openwork-memos-integration/apps/desktop/postcss.config.js new file mode 100644 index 000000000..2aa7205d4 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg new file mode 100644 index 000000000..a7434a4e1 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg new file mode 100644 index 000000000..b9b9ddc24 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg new file mode 100644 index 000000000..6e64a5274 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg new file mode 100644 index 000000000..f9211621c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg new file mode 100644 index 000000000..0b84722f9 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg new file mode 100644 index 000000000..507ba05c1 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg new file mode 100644 index 000000000..6b52fa907 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg new file mode 100644 index 000000000..ed2bd9b6e --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg new file mode 100644 index 000000000..abc304973 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg new file mode 100644 index 000000000..0016831bc --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg new file mode 100644 index 000000000..909d6d3ee --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg new file mode 100644 index 000000000..9c1fd5cba --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg b/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg new file mode 100644 index 000000000..b43c4aa55 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg b/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg new file mode 100644 index 000000000..0e40bd387 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg b/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg new file mode 100644 index 000000000..0ae338003 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg @@ -0,0 +1,3 @@ + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg b/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg new file mode 100644 index 000000000..85373f9fa --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg b/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg new file mode 100755 index 000000000..1b5634182 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/openwork-memos-integration/apps/desktop/public/assets/logo-1.png b/openwork-memos-integration/apps/desktop/public/assets/logo-1.png new file mode 100644 index 0000000000000000000000000000000000000000..bb69319269160d32ee9b3ba695de3de3af081bd3 GIT binary patch literal 3636 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 Z_y%=#6mKNhMwb8p002ovPDHLkV1h<4$J+n^ literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/ai-image-wizard.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/ai-image-wizard.webp new file mode 100644 index 0000000000000000000000000000000000000000..ce7e762613b297a60a304cec937c4858d9c9db73 GIT binary patch literal 2828 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/automated-reminders.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/automated-reminders.webp new file mode 100644 index 0000000000000000000000000000000000000000..31e16c0933616b6490ce423f365cf38332fca558 GIT binary patch literal 22282 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%0D4x} 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 Qyz;k~+3yYXH5lkP=>Py=(9(Qr2mln3 z{~K3mE@uk+8c>(Ft6rMsz5sCR@&5*edb)(|RZkTFYLdYh zuRs7GZld*6)i{V^cRn=bjam5gzRd`sL9z}RvhxkADeR1qCT7KOGkJz)6<|BZsL6zACP~Bb(lJN=@zUN}gEu-|ihX?#V+;jEuv4DzGs9 z$Y_25exTbGF~G9GClhz7wD$MMQ6vow^W(K4hp=(r6>t6@yTr&FxI11;{MP{k^F&T^ z{LosTz`eH4PqSO%`QaY^{{Ez+&GAFZ9SREIOJM`dD3Vk*)7CSuA}-E<=gytiE~}3e z6@P%L-Q3-K`gYnM#5S<;E$%22UMBNg!wh7O)B2eea8U!sNo=HIFLL$+k>@kvf*k!n ze?GO^z?t2D>||Tr)kSknHf4q1YZPjtdbu>g=G2uTA}ma?V>OD8JC!n?@Y0j^melYo zOCFDGAcwzyC$Y{H2YL*{X7(j(h&qqgbPS*6{N0-k9qkmz6!Z$-~3S zGU(AO7cdT;YgpIT%3#v?_&DW{%F15{2eo`x@}}qJ!YoZ{lW3^#%Nb!j zhfgX)&`cKfTn3U|y}iA2^G%uB-D-x{)hJnhgT>msv~hZAF!aQNUnrV>XRADSC~l5( zGs@y?Xg5YIvS=~>*RNlvFnfD}1;-9Ce2$#&8m;WS-*`#U%BgE-R1K)*LE&?0x=jaF z4-&uUm|bH1&`fXb?|r%2(8H5pdf?8lv9Z)2RaLX`hYoWa>jQZ_Pr(4;8`Y2Et4b9O z-lFha0k)m+I{qeto2myLX>EHtyG0LwI8HjrqM4@WJkO&BVx4p#D858o5v+ty3Xj)* z|A!JHQpqoik<@1&{9%-mpGpPq>TaKbg%9DHq--BY{C1ZDHtve|QZ0?|z=h4Kr1-w# zM*coxx-w3AKa1#B5$m!NfT@hayBH>kRv%k?dK7phJKx&n!!Qi(jjFiFV$|wX{PG>B zu2V0KiAX?iqDi8eefUh|SQ2QIvj3JP*VOJaRhV{@+PqLLnY1774Yn68CC&yR=nkw$ z`E6;>cO36r=`2|W5Nh?Cx zIs<5(r&6%XWF`}%7GSiQZHM;v8Zj~IPms#I(B!vyRrzZ_x}XtLv$(;7(JOx*T0JNm zpayo6W#e{YimTgg0?IHRtaJyj|M1%yk?N%(0OXzZ%V*lnHm+P0JYd&c+uTp!zQ@soR_HD<+IU`Sr!L9tfD61GaQzrj194e}$SZc?dM=)Gi)I z>u2!NIq=%z)zi!?rUtF_)ekQC8j~Aj5UyQLm3#&kH>)W+t#zp<*F>41TlNJyT!3G- znHYr=t#3n_iln64qT=l7pa$RpjGEXN{nWPP%+8)yYaGe%=F^&!;H3mUXP}kt378Jt zcp(OOU>BVEj85;oN77S6A*I=d=?TTAcnV}CbJ+b03ctb1D;uEY%S~iy;Nj#oY~@Lf z0|C&Q8;FYdv5KaxT+RYc*a5X z`~s$}fViUKg+3-;W0!Bf+Lz)pEbv?jZ-=%2G@>Htvu1b~W0?qQ{jKtXNcA3;bL&u= zmpdiM5;exjzm@Fj<1}IH_e{4lvmCV`y$1VMGeUEfQUl*lDI|VK8nY$=nIIZG;;={cIuwfeEYo^X(HoWd2W6A}2M;wYJWVKu`Ft$*20EeP9=75ggW zmi~c|3Il|etHKRbwdd8{{Fo3Nle!9vs z`77n1wo8*rG;{5IUj7&D#tPg}HNtPfk6`t2md+zA~5oT7@<`BB)~lg%I#4g#=hqlO#5)N5VTyz;guL zb)mI}Ju+!7(k(^6Qr?~(7!u@S4oz|V;F_3d(Y)ku31B-KAM)YaG`pc{TA;9v7Ob4S zKAg?e^LMHn4*~dmA>{5KZ;JC`da{;%OgKR8S4NazRJjp>?rJ*_uD)nO?A*jo>@ffK%*dcpBO7Q&s;+P*&(-fFBJ8!xZr}dVT z9R0Y^yQv|&W+?T)9!#PCIdKAc;VpBjouaY>Z8u84fvr&1h91F4ZKbE zyA!iur>_sUnAqY^=M`^E2aMDq6y!J=xt=Sl@UycG$lbRpqUSg1W$V0&)B^*-jX&&w z!~?+z;_ceHfIL6G5D%6h9 z;XbjPd6tn;JN(?}QZ+%oqcD1r5r}tsHi{pnPk*RXul)dC{xPQ0H{k0e&mV?BUI?l~ zawrQmW-X2kK0Q&c{BX~>Gv0!s3-6@Jm`HKgm1wg`-RP+#sTtxF^(Ca3#SS zww0#BygG?w@E3=IjRi$5%2%&5G8GMRI_)Bx@9uL)O|^Zq(xWhvXciIB00Vj+svU2z z5k5s7 zzA9jE(h4jkQ5Z`D(@)Osfh#F7Yzyq1ApG;H=Du&I%AQi!F2+aNouWG#e4G?99-M(% z8!(Y8j`yO>wdTds7g6~WJUvM{|yu!9Rz9l;q!0dnDb{L|@TM9~$UgjUv0oWz76mFF zAAJXniUiGfEz(4!W^i45+AaFFkTb$!>bc>d8eY#&iEWYYR`&HHqNIbtX-OkpORV4I z7(|C1(9c&^3dGK}1QGZJqltS~cgfpj=Y}Zthy8dLTR0y4cGI?|k=i=#Re%A?k;;{` z)W>zvc}AhY&iSTts+Ydul4dAwc$SPhawMEc#`s{Kdk%10vgxN56(nOwry~l(_x1m+Hm4O`A1NV*15St|1=a~h$|lye7oi2#uDnjU+kp%% zvNmI#HMwt05B2rAbTns~O;r%@bTp9^wq9N-K;$rKN#T=X9qM`o8rOeN~T}BWp&_SO~!m#g9KcAi`kYyvUgT7xlgda-Tsffw#^O zm6+$KW&y6Rx$FEq&WMVb=+C9(>u&`K+Hk#ZVgdB3@4+6w)w@) zKnf7^-=OLbJ@%{oXsX#;2QF*S&kfG1cP9Klv+zGLf3mfi;c}3RvP$h)P5k_PlqdHN zydr*0`iimpjZ7g|PN&7z$f|s{pjsyiE9mYb2L!#Cwf5**BFl(F*EZ8Iy0E1xS!Dwg z6jH6ev`DZ6ZoTMk;p7+*r5|iTy-YFt{mkc0$jfJMY3+SpKUNNPd3_(I_(*Z7-R_h; zTE%<5X13;;6iEn0bZf)QKT~uR-pI79c3lj2cVw6wzP@)x zam%=$KqNMF`l=xsV$R@yPU~;>w~LH)G*4%@iyWg6A@GFX(<71eUm@bs)?zan@h*?v zobY6Ge6$IE#K=aq?`~ft*tD2~_}3>YTwZ;n^!4p&WvJ|(J^D8{fPplc);8YVoX=kj z5|D9?`Ha~t)K8#tq=37BtG*ScN0Z1Ob%*-TCxgq}JsXBSKjv;6)~7l7szr2Ep5Xrg zuj!_O6cVyW+KFq+srO7QuNq5Scgq(fgzlz3x&ImrITmL1b5^>Q(K~oi^CZsKP{`4e z{(Wlb#{3f%7V)Q)BRt|zrFe%PF`0iKCZsIHu!a-ZSV{&;?-XfrpR_L$>(!_e_FaLV z5;wl$q)c1SXlQBu4iIPClsJTP=F?R-8vgYyq&gt?O8MV- zox~Ow8)!6e@we{Gfvt^1@pCr`Ci2MQjSDnxf>BsU9 zsqB~&h2J+!tu6m@`b2(Qp3kOUP>^J`X8{yOu}yA{m#pumjuKDR(EKTV!Y-_nzkaPR z6E^==_`LoXoWB8i)g<;VPyq;Y?qd3H?(ZVY!m2v^riUPz(b(9mRrFG0fMKx{499L z>wVIKcSbC43sRMidlTfcIZn?cIOReVL%#1q{?VotQqia5=gB zU?YdgJC$Ji9JV5wr$*4YF?_(N+hA$8k zXR1hKx&}Jq6zS0_IMVhx+-w|H$hxxMv2QV#>Z~ABhbPxncA38q;RfDFVt!?XgMr}# z`FWPkfc#ZHSH{*5M8-*21k$K_f3a%!*e|K`=#@Fjr-hZ8b6z#kgWR~jnYhyucHb9l zJ3Qy{C-D&jwaw!FoaC%-bMSWaC@+Gn+S0HeqH42X9c2*T!$)`(2e7yetO>Omrzs z>^&$%rd8Wv(53Gg_1DN7D%?t^(P45R(uJkMF9brPbC1O%75fSg2^+VY8C`x@gZ|UVEcd=#bxQQ8327fq!mDmJOdU z+feg0WWmM;Gb{rf5z1|0s&l5(G}@R#N|lM-Wk+UzE{J@4`Uv?ZjYh9P_H_EtY7u&5 zNlNLT{jHya%tKK^Rn+AJVOe>kArMDep5@&Nr;^FKRDny7Xv*Amp=GPpa!!&Uw-u_+ zYIZvpkb<#x5n=qh?N9RkH9|I~3tRN022W3Oo?V;v@B^(%Fw`e#a{+T~khQufjly*{ zvkz15lfCsoh)3NiYu)Pck>6k;XOJO7hY`E}hWFUvx7ev?(h(%55h48sL`B>1refzZm7Wm97uP(COM-)(JH(+}62UN&5mB0nK z#I!R34GnI{NMkfrSAoY()U}i32Bof04x!RrlEIXIDcAkYw(MAB>=T4o)L}!TilQ_R zg0z{ZD3^_s1G+gN`z=t2`NN$eKVAqMof4yX&Y!p&ba`~=W0SF*9kED@CsBVljJJK` zD5D}oHoavLw-##duLgf=QYqdn7>^%Xw5urnwJfqER7F*&{S{PyiQHr-UZ%p|Im2BZ z^g$u5%H*RUZQ`r@UmwDQWcCaK%>M2p80NOG#tLue);XuT%x`5s_(a~rn!fV)u3<|S zZkAOT<(4ldTzKT^_js51jw^fWW~3-fF+XP+_XUdsx&Q=Ut4$T|t(^MR^Rv<)%8)MV zl&l(y5Y_e%Q-sNQ_LX1jGAjwe^J($4s|tKb!TpQb(+9s|8-YhTK^d1vCep#U6sPhz zeKT(xO)AIwlL~3Ur7oqhSnpc%vB{l- zJlT(fxd=@7W-xXvy^T43*0L*c<#ZL zg8VUmRyqsv$-c=9{mYIR6}{3gOJsUAyXE~n*Wx-TnrmJ-6b#Y%&@6V_#VY-wu=(Z3 z|2+u=ji!yb576@EJr{vXbA+vGxbE+w&64^{BJ3^nxi_nBk}CNSgS09ti);qQeYPp; zoTXd;LA=u4I7=5%i|>_ekdcgIKQ$k}GuOJQdQvmY^jInvC5&w8TJYl;(Ng~`4{X>b zJvb%Y0BSWsyMka%Fd3;5>a0PLyMif(pfD3_*vyv$gRUZ&gK!*Dm{sk?^QjZ8Zqk4_1*K;vcp4 z?Xhx!u!X`t?1-ZDC)_p#qN?fDsFj6fTD>lEXtHQYM^XpwDCl6{f<`#nMaU^#ZJB7nRC>mH!{h4HD0kUw69k;= z!Px8YMH}gnN)Xa+hv1Zw=|>sQz!u=98WT4^rML!Saus)*X0yN?*i`8>{-YTQY{V7E76JpE9KsQ+L|Cz7-I zI8+kNRna#8l$Up{I!hRCVC2(~<#5KB}(vbQ$KCu4MU*?Xhlh z1rzrte4DqGex`eS`w6Ss!Dy)CzlVJJd@RO>f61322~trjr>wfNHEh60<{3<97vp)Y!LR4odlPBhud z;OvGpQHad!cD4Z<87gGsWkj46DhKmiOn$X&Rym# zzEAbGQQ70m8+EJeQrqDF;vdp1Pi151mU~r0cWgTB6tKmcf_NKeJsZ~xo$bvB#ai>S z1?iEC=T~;O9a0{Pm1k3y4=7(zbZn~2W5}8Gxu#_us@Cp6PMJaP@QQv^VA#r2RODa8 z9<>(tz*QlAK)w1a+7Hr8E8PxcN(cfaDHGaD$C`56n!hv-`8p(*fLOkI{Qh;Nop54! z>fTxEi9n+3%QQJ|s?u_yHxxaXOEZsp;;_nxJkYfhLfq+5P_y!hYm?qjJBkobOO^s( zg#e06P~95xEWxGagVbLGh*x6r4l%SXe=b77!wW6NNZvzIb(uy!CR{wcD_gqieyzFu z5)K*C0?_OA{bnE|dB)KX!coqzDydKpD|hb&FP-FGUFW3s8Nbo5nJygN(Z&IC9niWd zRRAN#lqxk98vLLdN10lJisn)Kl({n+2 z=Ge{Fwbze!)lTkRoUWm+s5@x-NkPV;hIQ*~3mRAADd*Bd%nBVJ+Fy72;2;jeasC?T#=4(_SOU2ShcUJZU8w#h!1^cNX|nPlrd4 z4^Nq=FY+amsX9&aXd#?V$t#zR6h=YuK_$Mar{+&{d#PMNZ9Cm(z_72p2AC7-G~J%@k` zoVx4Jq%{!Qx~|zRS5QN*6{OEg1^Hr94oW$W_L`+@_EHZi$FQnG?r z;Fa`DOa;WdM_%Xj}ugaFhlUN&;@I{=Mj^m?JFcqUr6Z6epFEH%Q5~& z@)@H?z+dv6qJGhtMdqUyJKiXBV@_xL@Mv`##Kl5Pj&`mB%Bp*@wAY%P3V)Onp4$<_@J znZm8fb%G5v7wM`C7Jzy#|E#bH2bB|25RzVd1;_?X=*U0ZYc-=EOwRL!e%buBjzm5g zFNj^`_bX_AnoZX#f3MxxQgrQ&c!YsWC7j;=%xUU_-g>>`)2kzY+*LF`Cf8eBFPJ0m z{|O0vkv(cSiVZEcr|gl;(m0$1Z4XgorX`d;0oxYX5J^TisUyKqddlrW#AVJ@$0FU^ zD!`q5v%Z`g!_lx;0>s0~-;W`Gwq9bRz1n|BwN1RN8At2}T81{RzqO#R9L_>_m{#xE zyjmx;;9*!)XhV&D%v5fN`LdifZGU^Dv5E1kYt3gmLt*Np%7GL!(KceIhWVRZ{#FaB zt~;=N@{w|DfV_30$cGQzKk%OPa5D=2GD6=$oxObSlykmjpd7h?#W-SY+6H7Tk&1=! zlG}YcZrrDU$DW_)484Bza@O!#Q^b~`We3W-`vqd}OJl#~#-rr2Z?i}>Q*AhJigC!_z+-S#Pu*|-8z?G2@!P)xp(r!yRjYjOd1 zm1)ycVmP5QRdgp<*`%I~xOyqk!u)JL6|6Dg<2^Rlj|Q|$ zS^)D)UJ8?$r_MH7n_-F5&W;lvvo3WJ9RBlwZ2lL&C4*lq4z#v%3$Lw*d^Q8TZ+Ql8e8G_+wXq!ea&=*| z2_9dkC*78;eq3q&;;t_G6}Z;m|NbX#c~0YowML()adJRQ(6+TNnJ>6a&?e;e98c!N z>T(nCRGv0Y(VHqV^y*A`>CdO)Na#-yxReDKnHcK0iXE|@m3W{-)zyNlEL65ZCMd(E zu;p)BUS6+fngU^u6W}^gY>|s9d1$?Hrh+fZ^N@+MNTvo0tEsiH8!uT+USFF=|EpyJ zU_1y}zem$GvFJFqe>TMa7r}T(F>RZ2zjxaEACCpo zrQ59mrr+qNsJ5gPXj0N|KZxo(;U3ES!`V7JbTEWG{|Nm}t2^*gTfR@J2^fU@*6R7J z3|uyYe`5p+BvB%7o{2{GFlNcfKPJR6@uV-x5(xzjWN1@|&r2);ndS{le;@?3e3TN7 zMsKQP)F!D=YhqWE8@w9lCAH-6TTU6=0H6r$TK2$4Ml)S(P5AZD5PcQ&?sRZ7RceQV!fL{_OLZ(!^HHwHUA-0b=XzyDH zmGV#rfL9AC)p*Y-&l+5&ba)evqLn<_?X4O zoy^HKx<;W5`^L`~fd7H!b z$sIDv4q#GyIXfx;)9i8IT^%e31kuXy{n>ktfec*nqH-l1ZzH*Beb)*S=<>qPu4$c5 zO01e0(Ebqth1iYxItxdiL|dk~{;iB_R8GPxfLc6|abZm`(#1&!80LCmm;bwM&kQR~ z5wf5AUbpA_nw}t-Aur&PqGPRX_)I8F|1W8z^@E+3>RrEu200z7%Ja!iV|xpLXu<4oq-3jHTJ%4Wx*n@?2N~2t}^GYWGfojmdTiB`~CY#`fk5^ zCPRP?tpn2a^x{Xl|)e?&izx`Qf#C3Vu%{K2|z^CN0BVH1O-!8W$>{8CA3(O?2 z*<#r zHtKl;jWl%zd>}Yc(07p7Znop{f2m?TfzdCoRZglT&Q>C9QgycN929NEGn2B6fq1%2gCV5R z-(5ajV}(=GGKkdST5=%!RF~aAeT`xx0Um%XrBbon{%U9(x=T6sF<(M*Bh(Gv{I0<+>xyP}5_p|P{uZh-5`q!}q zbrj|>hcIY8#^!%LYGal>Baj|lvFZ|zwkPVSNTlh#)xJI@Ui(mYw`03J3o94wRd&bR z>ZYzGWdxaldCfH9Vyrt|1fgDA-^w$Be`!50PB@xHeX?rf?73-M!F3Frrv|%;{pE;| z@vU%fOijAC5#EMibyGMd2O8aD@|TQ@eqL%n78cGtLE7g!oC@Der1-4`GPw=pD2Ins z{2HU^%7me zL>Rfvwj=mLD9(zGOXWVu<@LcS?BHm6?f{Yhm?+O>8`~R&$SaqPl_Z;f$G1}9ky!4R2XckqTOBWzMRcadoKjU^4!kUZ;;+rA6mab+strrQQ3lB=9R#Z! zgRy8pCfLiMg*1N~_zd0#CQSr^Oq+WJi( zigNJ-msO=mOlM?;HU-?%4v=SpPszw8K!LL|c=q<<?uF;BC`q*+^V3bvLH{d!| zq|gDR8pZvyKb*F%{?Oy-ytT8xr2LXp?WY`om_i}y`XGNZ>coryQfQ#|A8|$lUTcWs zwdS4k8Ia$9ezqSZG!+OE;)JW#nansN&*7UfV_8its+6RkC5JvSDO+o*f-(Rs2?Pnerl2 zbYh73SxIK@v5d71e<$08{a7-qYE+D20~=(~lzP$w9zi^@$w(&6cuArdc%A`ibfbh@x z*|UZrPC`Mi#7;7S=hY*Qf#O;_;IcM#nJ0~Vewwnvmp3bHR6LI`%5$Zr0FvznJgUrjoW3I1E2ux)xdye zm*KV5x|*85$`?__FDH9}wH=uTIlu3{>aVo>7=X3F^H^gJQGh>=52)W89iU=;_j;_# zMxoA=z3rvqLdDi^5X!VM@I~0YPYSXz#K?x}Yuwhv9j{?=eHYBJl|Oz+ zHR(Yj3I}`vV^&AXJ`6qOclIMfVN<*DeK*@Zs#qllyfB0K3HMGSX$VnSDb4qlJosFv z$3zIoTq9hJUIW8l2ex$+Ul@7ixQIM>Buuci32gdu9W{BKj{k%s>bpKUc^>`sj2v|O z^F4F2c8`g-L^B6f$i`cYjoa^h#oz;XY?5PsctF48O*!o8(W*F<2pTPE2vk$@!%yr06UnZ0dgs zN_Ev^wwUn^Pgh%_#WlvBhK`PfIhn;=jl%d7d0>-r&<1bkf<$fOiv2lZJ~X~2;}5?-3gtrh1I!>_2` z>Wxe;X35Uz3>lr`j9Y-Esw?F-+7^K3zhe3=_^UrgG|%&uEA4DUzNSHE)dD9U%)}Gl zn1K}KFXgzNlSayF<c|VP!`^u*NZ?fmNWkA@U6~)_>IXZhXZz@ z93y|zEg&GhC_o9hYg*IC*dJ!sf;;Oy$(guJFW?;6{6Xv#>#JqEBi#2bA1Vret)g+ z+PPodfLBB9?s_sdM}(P;(fNeBJ1A-3zmjohlarIdG|(tgd02k@Cf#{+OX#RFnqOQ( z;(9XpO_T>K5Ug6#Jq#&PDb@lgahK*OeJA!+x-19;J3k6)Y zkNRzVJSx3Dp#YkYe)+DwDzLZF9QnY2@8d~Jj(fsSk1xI97n#xA5cCz`sxE&}NlR$S zgIG@)d=s^PK?FCX;5yJ+xShSN)ZqR9Wtscgv21*WX6?$eef0m_)Kb@fTJ^*>`u_n@ CXaLLr literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/career-document.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/career-document.webp new file mode 100644 index 0000000000000000000000000000000000000000..b202719bda521f9a0572c237530a5233e1d9bde5 GIT binary patch literal 23348 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/clean-data-output.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/clean-data-output.webp new file mode 100644 index 0000000000000000000000000000000000000000..a3a900e674c7fbe8146c2bdd0608912acf5f8413 GIT binary patch literal 18054 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

q@{xvh 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/competitor-pricing-deck.png b/openwork-memos-integration/apps/desktop/public/assets/usecases/competitor-pricing-deck.png new file mode 100755 index 0000000000000000000000000000000000000000..7722812995d917000603ef77996b072442f7ff34 GIT binary patch literal 31960 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 E0Bm(O^Z)<= literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/custom-web-tool.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/custom-web-tool.webp new file mode 100644 index 0000000000000000000000000000000000000000..35b0c4b8592de47b37f8fcbfca2142548304d744 GIT binary patch literal 21396 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/document-translation.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/document-translation.webp new file mode 100644 index 0000000000000000000000000000000000000000..494cae10e667a9e1163238b1d3f153fd39052d50 GIT binary patch literal 19248 zcmaI6by%BC@F<#u0Kp+p8r)J^iaQkdK#>+&ph$6dDNsB(#X=~>rNxWW;_gsfiaW)M zQ`~a-e&;;TIe*-H_swqR-JRW;ot>R|cJ|d&R#bfU5&+OqkbR~7N<<&SAD>TR{*f@zY_e=UFxiU*j&>VLt{{)0{b7cBlC?CI|8{($r9KiKt+ zrrZN;{s2F-`oCb){|h#AcKr`O`T005CQ000a1f9gzA0D#(1001`kf9l}b002n{08lgXf9n2^Oq@+zP5x&% z@Iwi*v;+W73IG72cK`tSC;))3|37UH#s3Fyj1Nt;4}3X2JXQdEfCYdNpbT&Xm;tyR zAijr1Gn;}g6EOiB7^5qUPUM#mRawUV zm=OOR69g>UW*bb%->*CWWtN+Y5H+JdWtnVY%@#sKW%t7f%Gg!YWf`5#i_JmQrj1jU zk9T@Ym4=h6Pulmn^&?&|*o+e|1@F{81!)pVx{H(2l!i3v`}`TOZYams8DbE6dYi0; zfWmp~n@pfNk08oW%M?pQ7*Y!hF-QnUsq%fb0`m}pO*!4*^LQpDG5ch@JcQKo0(^m= zi-5X9O9EIwJong0K_$ouXX0l>u2^=-rTHMBIyq808H+K!j04>1s>)ZRhG4|Q+e2c( z$J1H*r9o6q)Tm);9z56tY1AlTk3kF@3NEcoZHxm8i|HVhh4`$bgK&E0R9UVk-*kk8 z4CX45@>KztkkoD=NFW4H*%+m+j8^Q|LqNTMf^AiwAb9$DfFQX|Mnx*2{1iADA>Wc9 zg6YRw7!(!;Mp)t*hXf*}<0d^=rqqX)ue-VZ*9WIJ{0$GL*=x&DC=}8nhgVQYE}Mwo zA_xh@>G9~qV*%mG*{Zf*MxG=$orSc6o~d&X@bX63zi0ViAavky zi|^{$z+D4}Q9u*tMfrd%1O;^i!?wXp%6x%}#$oD6KxYUeKX?!5EybG~Aa}1W*_t_$ z^l!txC0%2h#;E*a!@Jdp42odn$HU@5n)9R2AjYvpcwiKx#ETkR0QmBnqhn*> z%3U;srxjWAxH5<2<4?{UHp4oszK0NQL8D+tO2{2X|RkS7%Byn#||GPS~n!dSf zxb~YaoCp-Syz$t6`XFxZf`9+9<9F4tP(EED{z@V$0zNFOQR+I4O9KiiGjebmQOGcohCw>w&me`Q)KI?G=) zqt&3!;L^4JL6XxO+yQq3FtipEVCOXw2dTWm&Tw~i=G&4Uus(3Le8>F+#i(N(rxMna z|IdnH=_V=Q-(yUl5g}^4Eh794nsDBjmI0^k_G!SqR3HE^omql8&`p)R^XLp281S!G zDhbF2?2)sbji`Pn<@4+^?*5{61_6Pd@GAS83cZk`ynTv!W@0IA8rL_+W2X9Vi==1c zG3Kp0Q?I$|+E>AZI&s*oALbEMG3O7Np!HAN5c9Ym?)zUStWv+}<0udXNP*3bv6b3; zs4Pxc0`4dP3(FlphAq-5sGOwts#k+tw=|EWpU@r)X)YG=8b7Z+GW_4b2WDgAm_3$` z5a&n6(jX!HRW+B`BVheK5U&6*mF%02Mxo4Kv;zuYI7l30&CZV>#Sa3q$q>>(euS(h ztiai1LX~VXB0ePH@xIov&9IBN_!p@aTA8X8-w<>uVv1KqmQWhP-{a25O6_}1(|3Ga zcPA6rmVQEpuMS|!1!sT|DJT;_%#Ih$=KQl5?zr9)T&DK)oW%WPWmK`EX7C4N6exbh z93CRT8Z8RBx<9VNw1W|CXgXmyAy1n$8VV&OWh9Rf=uP`K@!9%xW3}#{X^;>LKF3YS z*e=RAg_lp&l@GK;_fQNLMZb`w=T?N{<<3gtea1s~&7s=(fWl~dRkP2!P@L7B)e2QY zDC4YEuz*kovocy43L!;AD+_^~`AorE6US$D;kb&+BhP21igd;g z%LOYwZfVLKPC9H&j}_0KP9ltquXIeBqBIRO9|kJJ0;O3^q{K4bj~ERX41^Mp9G)19_B21qnQL;k%-Uqd-8-6H-cX)?IM;)|Pb5uT zx%zLsZf%YxI%CH*`8r?=NZ>AW$ z=V?Qvvz*Um%Lv6{dYq07-WwsZ$CAwt;(#@LZCRKR@st4|BAaaY9JjMJo@#tUYIU}$ z3=wS*Ge`B3npZFw6H?$Nn#(g}p0QV4Nfpd?dGRk=cbft$*n?F-h=vJsP3|fYIN2A7 zGuvX^pA2yuW!SJ^?0IADk57J}(RW6QhQig|Gy>h3)!SFRKT0~{GBN=N(#7Nm^Jk{+ZhZsufXdXB zwTh;F6y@b3y=MRs>`s8a5n(^Nt1c@G^>|i2P;t*5YPt>9S0Cv;2~vHRCb~$~8K@Yt za&~rA*NhUu&QIR6&r!UcbWGYZ6P0J3?>`Xg5Q58Pk_Bc}3@C%XsNX%%EyIPf2(-;n zVPQehb%vR-H$b;f@hg+(a9$`Q)051hc_1|~S-z@VvC6^NrBR&bLJnOF7npm5`0)s5 zX8&wf_k9=j6F9tv8wDc;#oX-7*3q+Cql{rU)S$0gSm>Lp2Mk4&P4bg{eB%;MUD`hU%{3SYtz?A z)bKtt>gHi^{3$+1y-xxj7TsKwObYdi=VOGv3#UspHcPQ zQ{x(ql;S^kH&^cf<83qQ=kNH&3CAB)F?9FqYs(jg$bHRZOL$+`#wo`FF+9)${lKAg z7~rb}$v`^~gox*SF8!oTMyg4Y;6jcyP1({XkqRxHDwUZPx(j6E#Uq_7s(wOA{U7=H zb~fk~MD|=X;WIR)@9czQfdtQ7D1WamCbAo1ZN4y~(Q&2#Vw!ax-4Wa0?r3kLNZ`hO(;A{X=-wb$@OI>-sD$KYY?>0mN+fBya>OZ9gz|LmWM zUY;|j_0%LZ6Z0cO1innTIZLTB!4u$~1JNIHLVT$maR;->ZnBg?@V1T}wmN!5daSU+ z#Zp%A{+EAR0Gt@~-AJMq7NM^CNZT1UP_83=&d~y941>Kl%0kW)C_@pJ_4>vpVLM&d z9{xCt3E-E6bDS@w`O;YpxF33S1Af3K`W?d=wHT2{^T$+mbU`8Do)DYHYATjM@SeXv z&H-*)TcCj1GAEM@zJIegRxMVf7J#K^Sk!`3t8adnzoN++ZVXUA_lq4w0M^->7}gfs z!1KHX@qqqo?~gc++#o7ulRfV*A;vTY+!}3nq`Z8HK0howc__M{+)Dz$*DIjhcFw8X zhA3Eg&(OMoLcwqp4^pM=RbbNt{B3iQ>R-uj==(g(zAcW#EuTq8BcP0K)BdJtoPnKV zec~BfSQ_4bGO!+G4yqkk_Y9~6CXDqY*ID7gU`&A*6YHL0s?<9a(QpqbhRKh< zwN$0xUo4oB@lS9*hq<9x%KeAY@y$b0CO;1 zj1cnF9BwH&i5lC7h3qw%s4J&~J~oScUtrl`AcI46^|+<8?nt71RG8bAw5e8y zwWyT$GO-gD1W5S9Zy=ZK!2#^<=6L$?PB1Dz72}pMQvrqC9UU!_fZ;IG1A}K^5i-QV zmeS#TlzO{v!EBjHNK=}?L4nfYHONesHIU3_88Qz_S@-^Ubikdm^BLZC&MAjpV0@<@ zw|E9%f=ipp?%jFO0BfH73!m;iIA`7!c<_M%I4Ly9i+^UGe#(f>wtR-bc=by8?XjDe z+;G%aru|Hd{7}^D{@K^YyA3G;6`=_>;~N~tDOO%abQt_vM`gs4pxwq>8Y4OjqU$0^11FgYZ&P znz+fOaC!)rUp|OUK*T=^))vA$*2V@8qq2-3?^HyM_M+4i;NZUSb$?geNbsBBLJ0tJZ^uYSj^6ju^xW+{KrdMY?c?ydi z4{oUJRVUDmW^;Kz%{vg+4^tMb2&6dm?|NjSLxU$Hqso1hVSwSOHPiG<{QH= z{^8bg)Y8$fxp-SuJyGZ*U~2$5XO*NXM7 zZ>=9+Ui}-JNQ)4aQV`jElPP(yu8YJ8P$$KL}GjC7NVz<|mx#io}06 z?rzal-*LdTk-K)oL!3U2HtvDp5hdAtwDO_7m>?E3M+OGNgAO9mp@0d_U{wU8w4P%?l9Y;ez9(;Y3V<*ba4gi$6mLCH+*E&l|uaX1B6&S(ofZi9*zn~-rh+F zBPoQ5bd5#W-xYDPuW{cTaJZa5U$t;B;P5c%^&PK2m-QoAaal|mETrB~-zf!Y^?L~u zwdsnH5t5?^Lk8V{-BiYY5D!d|W??IG!ay-|4<@6E<3U^tPEzh-^l@2I3~^a;y)0C+ ztjahzAZ24lb4!3FR5R!aG!SgbsEiK$^LfdKV}hrRZAW0I^@@b6^=`L3sd$>oOlyO7FEVJIQ=+;hz~%cg}5Kb z-03*h9^Twu-HgnKnc&0_=90msjlrh&sVP*uKLrR0NjuuuhT}z*9`s~RmnP0$PcPXd zI?_Z$5H;8a;76fR4p`)TJpNUsgoMZtLe!urq!Y~dm8?`cdJkDitv*SWj5JSH4g)5? zs`{cD1m>CQ$u}Q`B#=f7De{_Tz`~f!1KX3UJYo^_^X83VlgVa!X7~d6XdpCl_PdNg z5U)-jZ9EQ4y`8a=m#JO)!Ic=R_E6+!lZS}PLf&1>G^ObhfjWCA`uY_0rOje_pm^z6 zAf8AZbH;v(5)G#yN}Nb=vU$Ira}Z#*4?13<#2UkcrQDZ`qlLwU((FhN7B!t0Wn~7l zs=_87AyWhqo6c)J=3p4>WS?kqCtC>0%~%;4=fIjAA7V}vWIiGKO$Ns}5NRGp1$hYi zse<{zRf=pT4@QSQU5GU&B;@T3;~G$iF9a@!fTFX>V2nUZh*oDh2;OIj^AH>}L1 z?5TNhd2mxwkcxayW(6W}U+Byl<12^d0TE$ZDOk{l1;qh{z(=3)x52-T(H0jmA$LAs z&;ZTW1R{II$P18)(EtKCOiL(X5CyBuFzKUci$t5VN+XQXs6eeiFoz;arb{jp2@Fw| z1%3TNW{i*pb=9Ou3(W=c!6pdhsO3TMd1VloB2fI~D^R_QPY4^z*ok6GV}%{kkm4X@ z&G6yUeJ^4{@DS{4i}Wub$PfT9IJ!Coz?6o^qZG3z(GMPvRx=Z8hnVtb4xy@x+eiy& z+yHP=*bAMom?Ae-AR!h96e$Z~%v_d^9vYNJ@Guhoge#VW+9ab?m4%)>O*?*LRc8VK z_SC>p#y#h6qN}ymk^+y1^VusB!Eog;oE-rkp0%V_5MuDrdWSbDJe7m2lwmm&S4AEZ|bIu+>4rC4f|7 zJ0NA5@05K9bvJaEa$Y`^f_Z$Iwd9;=9^H?mw*~tpgax+o(&+tMZ$S-Z`UdqtlkJ^qqcEw@F*LSFN$jNtn)U z&l#z!`xS;?Qb_@s7~swHz3_(7NxxaNMnTWMs9oWmXDS(m|MRwO#gZP%}Wf)LfV<|9rqR! zH%)OmaFQ)bhxn_iUfl2W88<**2Rh{QUHy`YjzV=d`QLrApB6tc_!r8{zXG(B|@Sr4pTi zGknutJq_x&w!ZF@F8agB&7l2|VX{GUr=~UQM41+~4wD1cZU&7BKZw-V)DEg7@fJAO z;M0_Zn^x^Qf6vh!dIG`i~#u zIuXoS$tnlf{D=!*Emg?;5*ddajx%k6iT@Nos>*CYh3{tCMOC(T4Ssg=x(aR0$gZ}0 zM$6Dj)La&%_OhzXZnVfZ>8#F4?^fhPXZl$L)ga&~`oPPVQ2j~cUZj1#54#K$pSQS5 ze33Z9c)2;dXtl#o=!P1%QfXIazCrdY`XpZ$J8Px}b=Gq@&^^&Z?N_zkF>8a%mA)-r z{|lY;fvj!<>HH{t!6Aai{tLOhI)rOLb!P4T-$uJ9Z|Wg6ZR$?=-~L7O#$hWJGHOX# zV2`D^q`0{Eceo3qed|tYrbL4lsq|E|03x8XOa4Z?`}+PpTcoR6C#m^wvL$g71~mAMORvnNoo zYyPF1w$184!)u*i`_$wbO-_D&U-bi1UHu{s&-qcU+}4-9s*>#UQ7uONPw%UuvEm=~@6+K;0`g!t+K|m){-^kPJQ_5tNHU+%oH)vR@Wo>IHxgbYu2m$)~6f7gsHG<@d z=ZK>ZW#T`7QUO+m(DNZmn`kCh+FC!~3~VOOcoe@joXv=7>l^2DmYlfr|LC?y`mE*B z{aVBxA6PlGBR9IKU?Y5m?WUV0#-Tq%EyCj;bD`Ej#i=&}X_#)S`}wYX>7^_v-R8Hr zls6TX`_v7i5FVPH`R8hly9;F@A)MNNa?;=f|E;X2HFX&FbN;U+d4oIF@?N5O31HV} zL9%|0*u`gvC)llTDmr|WZZ=rbDK^Ln307a0UI7Big(3!)F?0JR00R6q_Hao6uG(jt zn#bpWF_FK(w?%otFG|!rjypml@1sTd{PE3NFM4?ecZO7|`eKULH!7;7$q_Hj22c^- zXqlFuGhzyaxTtV2)(<8iF+4Kb{wu#x%c*?5y)_AB{f8)M(BsRp!Z6@}n+8sU7oohi ztN+`6=t=|bF+SfPZiNFda=~f$bZSmm8ICK(Aq%=7gRWd$W$kFBAlqL_+>5fMdr)?I zkKlWfthMvcQ(0}*&zL~Z0rUC$o4L?Cu7QqK>}LXD>JDjE!zm@j=;;razt;`l^;bXtG^M)FmBIb;w|;e?$~VudchWe&C@e+geywJP)v~5>RNgBw z|HkC*{RSQWiq$L0IeNDEW}pcHTCUTreHAsDcxs-!yU*y?QC0H}fOmbWNtiC}?FhE?< zChwNfHAfyFLJ@ye!`^t7_I*>oZ5M5!f7WxmKGE`Tg&R&-HWRpi4m9sC5!=fr?$ybQ z+qdiYv+&lp==sfBhsM3!7Nwkyd+oBTr%7jg{i6#dQI;2AWo$jZ%yiTH91-h$g($F8 zjQV)$({wTycSgJ8p5~nU!@+}+P2hU&k<(7=H!Ry)r|f4C(tvaGuzU%h!^=a?TtVAv z>!Cj%F!6jlySBdV)g0|_A_Dm7IFcao@+_#eJ6S(&nfY)p9!iVf^<)j z<83;QVnVD*Zoo9YsR--%4}=jhZ_=fd9`0z7Q|n|e^6z!kdCXY5CSl19&bfuO07Vs9^Y~eX&9wM*wjT6sFhx;S@^4F6`yMOQM#cFwt2eOI~6ZbksJ1F zzi}Q-=j&wxidQ6GrXMZ;$u24LzAo{eWSOyadP^_gmy>r_cg1`Ea4F=zb~s9(9P?)= zja4O((%U>$&w8Cjq1db=O9&?; z@~W_NhWz9NE}sC^zqc2&Nx`)=H*a?==f3so_D3<;TxLnp*^3{#;e4q~t?r3>OI*ei zTeDnSgdR|Y13Nh47fgShH~X}lYuaPF^}A~n>1s*w%;zcWzSPcfo|i9Uq(Ace{)()8 z$=c>Iwj5qzzt#+kr)WDAF3&sRaaDB3gw{L#o#p&!0;Us^%x70}E&05FT@LkGObb8Z zgewU#P}r~PAGkJ=Y~26my7=7lx>SekJI_nJJ)$}3(O=F&71vLnZ_$1hF14*Y-?skR zM5L7FfBDyO#bx->3DV}mE(Uj?i4CFdPK{(9a{G<3_^03*@aC^>ZcuIWva3Yu4TDu@ zK#W=+{D}a$YkEizKL32gx!csY$q_8ao`ToII+YSEVp?5$qjq8Qf?`u#EiPVg?Vp-s zrZN_e^=Ad_LLa`FRz^VHwC#)h)tGw2IM%N~V)>p!LB?foUi2=xytA&&)ZnRi4)Le> zK@Xzsh)350@)7yZejyT9x+9e>g<1hqvb%~Fp)v9_mX#b{YhAunNw1xZ{xs<{ z!XUjx;ql|Tk+v+C7N-H5y$kQTzxuS=8v+Xk zZSB0$F}~O)W1bV{ZzAr0__>6ZZz`C*6WM$#x6J*1@~7@Hyh;r8we`3o3t(~kQ$+c> zF?i*$$!(=slI?Iaqzz&B`$zXkXRQQB+x~a64K9o@t5J$uqEYW=ZGg-~-Hx#I(J5rY zQ0Rqu&EiJpi!tsK9nw!KRSoJuOa}p#s(sq0%`(9Ch96U>s_HE`!_#_?b!&0E~Y7%Py9BtYELuS7)#djvY#GA>rliJHh|{9wevoN=Tq=Ud9nkv|*Di35NJaiI>8;(si};l>A(>$S6128_5)RVtVVbB4<~ zl4He@NxyA|)%o)4m%mo;4{4b<8&}2K$&cx7OeKqsC#0J_mhC$lDYgjc?qk05BgoHN zbz**A>;t)7XoHOg^>8bW&1zwI5*sA;-f|=nyDOS7SIvt@gVBNu%xq~IW4-XJQne%< zXFD5L*Plz!Kku0{&i~PGN`=L?b-*^HQL^!$#&Em7wuK~KFXwNLnOIzlkDI(RvY{+? z4Ps(h4#l-Ot7Dbq>cxJV!&-L5D*a^rQ*6`|k=y_}P0THoRNlA8%T`3hr;hKh&%OZU z^-W28o(7D_|B`?|us9j5j~Tp}sw_rRq*z$me@^|%)}uQDI3FK#f!-4`oC zMVF#d!=I|f_(-g$rXjbtY|d;FJNYECmyQOJy+C2@REr?XV-6k)TO zws}R}D|u+joW~E+H3N_Q-}#~)PuAOJT^myox=$plC&}Ebg-DDqAqKxc($&6QV&_vf zr*+{T7CHHACOxiYvBU|;8m#}t-L~i!Kt_Yjq+KBW=_CI?TrQKW;w7sZmP4VznRlKw zYvMtgPMDG(tEuMS>35=RRF@jCs`*UsKYnsAj?gYrlvaqA+Of_Izhi$~xPby=In(V+ zFnzS|Fk>cHb7$#)E^)V0J4(j=R_A3@=P(PWoj#zw>S*j*n6Wvijj$}QC3(4hGX15Q z47h!ht*&u9 zLA}dcmQ#+35A>TO7hhhN59mExtyZwmP$(J>4gp>MNfBG`{GRX)%l=mTL!0#Pq#(r2 z)Sou5)_Us2*1Zms{h(UA5yh_D7p^eUZLe;jMT{3ewvUw%sOn?pNo$vuP5*}oCrhpN zVt3#*%R{%{wou(yCUbBrNM@ z6ud|NF^)?1F4hsyY^I89XVGNM`coT;C1#rJQuDXr-ADD@mm3j8tVa_q)j6+GPw)dE zYx2P0?Rww()ty%aQq$pf#a0b+_>(@8i$yq2!;7(tF14F$yL=!<2P9p>iAy_m56j!PAB9LK2toyvUo{h8SCHXX!#Y+S(Z z8|!sgAvP7P@FE&&(UA8zNoP*`b0_;%Q(nP@SBxe3`p@Cl`)v^OT6JU9M5yVDZ8VlF zV@J76>?^%4?D=&B7qjiMi-+t?NzQ_&3Pxikkezt2=|g1N>UY~S>yh+1dmQw8}(bSZ-@9n5JQJ{X%&*2=>wl|iIwTj(;^S{#64lhS^K5w;4-^ToC zmMH2YTQJgYyjdD5e67d%O)diW)XWYkNZW*YRWQs+jFs+R z+0*%4*8;=fQ(D9Lsp}wrvcOMicc=KYQ@6haoxWVenrf5qP^RMkD6k^XR9egkls_EJ z*#Q;H=9K>pvg|y*lvQXv*Q4GF%^B+B(AN089jN);;p}6F(OXA#Kx|l`y}D?`U6)_n zG4qf>j>)$p)4+Xvg}{OHK$w}V^kt~9(sVdtzs)h%DyoL~Jtqfon>&SB4DbB=c37#~ zigw2hg(3%)l-Y?+`?0if8oA1bf+p?a5+$}gJ^OS=Iw8z_ql^5Kf`@bJh+pQL-+Fh|T&f+R_B^)a+B_RcPLJ^66p&@VFFJ>d_)}L$wI>>L;K+cj zHu;=2H%}J>oVPe8<#MfM<(bM{>b3-N(W^m!HVFg;9^x{vJX39y%rSQcm+2d(X%a<= zpWnYZy(lzOd+o|c(4;}itfUZBhnxFD1;=H-;1U|@jpdf?fAq%?ZDAeWGu3gNeO`D^7Qn)Ux*lrO25ZJ zZN$UbP3xvdLuNV{B!U(MV|mX0mQK9UW=sT~9vby+&^A`MKKfJrAgiu^%6)$IRLWyo z+?a{8gI~8%`s7^;Eo)pKomb|Zv@p7_7-Dgcl@fVMc)VP~@-^nf=(Bs=sRhSqxN6GT zbWd9cUZZWxN>ASnCNcTt)(^X{Ye;PqMfzajKzm!I+mos83fs?_st5R|JEeBVq;6DN z+G?YMcFPEt@tE!ZmKXX_@{A8*3kGwm>4KP}SO_^qB%kew)fn~R?Y#Z5(c96rd60c7 zHr{UeG=}S~_oY;k3$%eWS_B@cssA0uult5U1OSr}pZ2vr<18xvH0XTfmZLE~rhFH< z27u*HorsnU(044-P%|_>zKk+FqjOS8Ul0aAuT$XqEfg5tNhJy~ezU}uN*Dh+R{Ua{ zD1u6t)T5xa^j?N~acu~#r9O2yQp6RUI0bLCd}H5yY!l(TI{ZC5wpzqwqT~HYh?xVf zk{@=r>)?m6;V=#6sZw8oKY1&3CJf9x=Rz2q+^qSy8NN(N=>hf*WY)L)XV5kQQBG2e z?|f;s!*tyKHo;Ac!uP}WaGuXtq%!S3L%r-PMR~Zj+_MU^7e-5kZHvHLw8!6!!XXVK z1t`QwWm~UlO<8!Jip=Rfb?}9;O0GB-7`M{Ow!(VlBcg{&D{xZRC$nx6YHs1@ja4k- zw&JLu$y3_@>k(F=P#pig4<2W#4FyJjQbxD?Gx5TkGqy#~Pv0rtS-7_D2aaF;gMuYH z{D$`b;H>c?%<}d`YNVCJFtoX;Q>~3A51*uBg#IF-36e*e;LwiFTt3c!$y7Us;`iFZ zf{!>s#1fg^7`Z(= z;T-D3khLw5o@SLx&mE)Z-jx2-sz~jG&imWF>;Z={zWle7fSV~gtW3j`JI*vpq#f*c z4o6j_bh|&lUrv%yLxpCE&G*+frL>C#S;x;GEd)xtO;7S#-IVY|u3n+`wAmmXk{@kU zG`OF(i|k>EG@31slmd_sF$F^EyhJWoEEr!Xzmy6X9j{W+JWhgz_=na{)b@R|f1A zPgL*Z*2pNxZA=6`6^@SokYJo!e@riV)bO3P`W6^HURzpZtj{yMjKTLP%FEN?VXYmA zuuu*X)$=yHko&IT`unAex;pz0Ztc%bq2i{uCO!3t0li;$3T|g#$kx7lv*cWZ03`iJ z#!c?iLd)3xF#wdsn7jAT)w#Ae8?S1xnvAlD=icG@=TvkCsTZf>EcBQ zcQaPV*w6Ei4ncWwsyr!JRAHVab~Wpj-{U2cZ5#44i(l#$SiY=A?LB_qY~i1H#<$;I z5bRQ;5I%X9+D0T#fx+iL!>dxHktWI>#RT5=)E1jmtYx}kytu|r|7dX?{Zy_M2G0ME ze0_`ZYV%1YPK;LzD$y#UXcAL#>U5pswbADw_r_YF{Sy<4hbZc%v(|rd`-pUx>~gcu zGjQc8OQA?mW2AHLO9Q8m!OKE_i4w;&cb?+%&<=gG~_Od?3 z(3|#VYme`Z?;v4~vWj;GM9aRZVAYa7Pr=B%?^nrt)eS=zhTS&8lOnl}Zti_B{7jlN zn6MngmKblg(s-~VaC|6A9Tl_`_V(6l`}h_geXSuOsOQhgA^Oa8h2sT$aA0u1H$AaS zd6}xj47(zHS4>4i)OJQ-eDMgU{BPm==WqoD@qZ-ky~y($NsOGKRCKKw))4+PAgokF z!@`Lr<`q5#yIISY((ke`SEY*OAYl~#dfw5!?vUgd1J5b=JJx;fPc8x{GPVNIP;@p! z&%c_*pE^oavbc#V^6VMEhcjFgVogo#*sD@oZHYd_b20LoL@iO1KqM_+o*3LHIH?S> zE(R8ij~_MWjbZx)Z&xygE(}Yqzc%ZV6YgBTF`f@Ar4b@3k|xx+2ca_)uT(>!$n(6d2nC%&~1Etq!F9_Yr{*j z9Vk<-n&Zo_w_aHAafm)rgYf4MwRKI>%r17*+P9RlV%JY+3num0?8YvB$&A0%{Caob zb7o;l@y#PjXq)zQzHy9nEnZ1Yz$=sU3++->$gh-=qrGWq$-j}<=?PUmv64CRd1xS? zmBZh4RhC@Y(b|ybZTGm1pU;bryPao*3i)At5_`G4q|=h(`tbL4)sh;on;s3n=>J!1 zb?U;nd(j{-QjNcA&{sKjel%`aJH(&LF+%fGGt(LJheGg?vq$ORu8@jOvmE`~iqnQ+ zoj}4TY|@oK2$n$2Z&!0aTZ>e8k9QM~zw;BcG``N+W1QrEEgzUJ{eDbGTJ+bABKh@%vTf7X8jprflZ7HiO1an90%|eVuEIh!Qh{?r zA046TJ+U3JWI5~cniTq2t+|2=$f4gVHW_%v>W+%0A99Z(gd_;oo2n7=$}&sISfcUJ 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/event-calendar-builder.png b/openwork-memos-integration/apps/desktop/public/assets/usecases/event-calendar-builder.png new file mode 100755 index 0000000000000000000000000000000000000000..ec4d1382f55079b2e7d379670da948fe59a64bca GIT binary patch literal 10030 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/export-to-table.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/export-to-table.webp new file mode 100644 index 0000000000000000000000000000000000000000..39144e29ef55ce97a8ce60280f3e708c0872ef02 GIT binary patch literal 45222 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/inbox-promo-cleanup.png b/openwork-memos-integration/apps/desktop/public/assets/usecases/inbox-promo-cleanup.png new file mode 100755 index 0000000000000000000000000000000000000000..0ca9512b6cd39d3ddae75635851ef67b5c52f637 GIT binary patch literal 6400 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 O{>;u<7|{$IWBv^=y-&#i literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/landing-page-copy.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/landing-page-copy.webp new file mode 100644 index 0000000000000000000000000000000000000000..3cb74bb339ef07532c0976db9ee722976eca067f GIT binary patch literal 20858 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/notion-api-audit.png b/openwork-memos-integration/apps/desktop/public/assets/usecases/notion-api-audit.png new file mode 100755 index 0000000000000000000000000000000000000000..2ee301a6188d5bf06e797c215cc4bb87f5f1c722 GIT binary patch literal 5158 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 KS;cAZgns}t!zJzj literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/organize-data.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/organize-data.webp new file mode 100644 index 0000000000000000000000000000000000000000..7ce1e8e6797ce295cdb7f07d7370453fc6abc5d9 GIT binary patch literal 1836 zcmV+{2h;dcNk&E_2LJ$9MM6+kP&il$0000G000300093006|PpNS*)y00DPkkZt7L zAHmh&R6Zi&JWaCaVU$;`N#YAUB`<0-4fB(KO$R_j0 z^{>MHd4DR&?vlU4dqV67cz^N#@%zCUfBi>uccY)#YXZjy{LfZA>U~H5EBt4`PwM~s ze!oMML_|bHL_|bHL_|bHL_|bHL_|bHL_|an)&pJ2`-)6-^*0CNjQ}O_daGtbIgLQu zn^p%^RaD~q_Q-l9`loz$kfRrD(=}oHgsl#p*))#Bp~4a^Y1ibJqeV3>yx zb~GOnsZ-+a?12QM@7Q0{CJLc0mgZp*=>qgV!0m$wENB zM8C#a=|g?YnyP~?el^S-D%WBO`$uetAsQEl=WFpRh0Ev zEn=Ix7`8R`%Ia8uOpJf!v5lZN2EZWDfCjb$lQ*q5V>$k3!WUu{q|ElX0hV-T{U_m@j76p->+Sy3^GHLi^Ji#HoJNp3H z$Kw`oCgfb?wxaMNVs&QfUgjgw$J{OaS(guuXXG;PxYR^?zn9!U{FNP;C8?GN&=NZuH1 zo&A<4?9=iGb>*f9IL@gPfJr5)o524%!zA;2Mo%N8oG+F_S(_hDIsT@LD)JKP&poCZNckwPx%Li7)a1@>Me2cy!P zQva&{rMhyDZzrbllp=V#x&&)~tph6s=e86a;AiMJ6GOtY@Q?83F^X0;Lv_aWkQT1n zB`g?3TShPQwAjlu#CmO!w`e^v_PG_;xfgF*ktH9e*8|8(o5Uq>T2lMrUf`H#ZHIKO zReiO;V?qEPCI6XdEJyNyqc%U^H{_6~*efK=*tSB-{CJ=V0>PzU(S)tn)%%(4lDhxO z-+&KR9@{01u-S5rFR2V_%ge>az&q?CjTKEv7?IAB1~r#;8GaI3+s`qv^q%oog>lY9 zkMV8g6H{99Xybsz+w-5N_`-j}Mu;jgc|y19Wu0~QPXdx33Twse=yj`$-|1~f{TOIh zL&u2Y*;%v@7R-EG5axs6T>;o%?_W5wgvpy%Wu8L1 zoe+Y$_0`1t8l=5QDxX$t3r|Q+C&7R~L!14&rnGzMP3++{=tiU-OR_mMl`+vDzw=@| z$heHF$q@HVlpq!#G@tAMUo&R%tO@jEDe0}lK;$o-^z*!1y=JIDAd$gKQ?In&e5>W3 zY0VEn07C94ZO4vG8zX2pfH)8EPF=Z>-F)SsshRQ17e*=7xO-Z;{+nmEm065&%(caG z(e}`?-9QDI(PrXU+n>v5=`>vNhP1jWji|lV1KMke&HKM5U>?!1DeWOxMv4z=+nu) zy%Cf;8JaV=q47W=KbHyO=cSo(d1R_{l#B@uHT8Dariol1E*=|y*xAG1K7;}B(SXn? zsz|gEhcvi&#G4#}jA=R&2TNuKxitR!01b_c a)KZWR(_A9$(xDsT&o|@B>A`>i0001LHIY65 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/personal-website.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/personal-website.webp new file mode 100644 index 0000000000000000000000000000000000000000..8d5a4cb2995760955a3ee16f3c8a9b70ee4aa17f GIT binary patch literal 1758 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/polish-writing.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/polish-writing.webp new file mode 100644 index 0000000000000000000000000000000000000000..389483b495320612d6c6b452de54a15ffad619db GIT binary patch literal 2928 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 a-Punt1S1sCZckxEz$4MX`R6o%0002Th`l2K literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/portfolio-presentation.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/portfolio-presentation.webp new file mode 100644 index 0000000000000000000000000000000000000000..31cac7a6af21a6fe0360bb2d78fd058fa08a1bd9 GIT binary patch literal 19626 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/prod-broken-links.png b/openwork-memos-integration/apps/desktop/public/assets/usecases/prod-broken-links.png new file mode 100755 index 0000000000000000000000000000000000000000..7722812995d917000603ef77996b072442f7ff34 GIT binary patch literal 31960 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/professional-headshot.webp b/openwork-memos-integration/apps/desktop/public/assets/usecases/professional-headshot.webp new file mode 100644 index 0000000000000000000000000000000000000000..27447e7df709220944b0656a3fc459b0d291730c GIT binary patch literal 27364 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 D7(?3; literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/assets/usecases/stock-portfolio-alerts.png b/openwork-memos-integration/apps/desktop/public/assets/usecases/stock-portfolio-alerts.png new file mode 100755 index 0000000000000000000000000000000000000000..9fefc10d815864ca06757c4848c918097506cfd9 GIT binary patch literal 6425 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

NzZCvz$*b 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/fonts/DMSans-Light.ttf b/openwork-memos-integration/apps/desktop/public/fonts/DMSans-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..14753d29775c7caf5b681716b6be431c85d94f3b GIT binary patch literal 56328 zcmd4434B$>`Tsw2ZgR5_0wEh)LiU7^ePsztfGj{rSOwW6Kx8KfVbLn$j*5zkJMNac zwY9BVDWYOTafynEsYS&|snyg{s?k=s`M;kz_ukw9vG(`>e*eFJ?&~vW&U5C>nR(`! zXP$Xx&WX}Wsh$LWN>z+4E023=z<#ByX-a8TF{x(i{L8`yDV4v2tCbZ~r;pB@HT1Vi z^&6~Iw`Da`v-2(s&8{HrH{>#Z>a@~nGa^U4!}*<@&sw;$cJ(KhAAMG-fFvI9_2SyK ztL152kKh=%c*TY1CpFK0Ua5RQtBNGxIUljMN1$Ayzbb;c?su9OI9{) zeD#BgFDMoM0D0cIqJClRt#AMAH%d7uucK*Y?Z(x*P3&e zfUT?R*EXCNQk`^Lif`?X7 zQ&XW{Qr&;B?0P%C(M54e-744h_ttI>ajG{Zw^SB!dD=m0VL|IDR2`#ZIr=HB!`#OR zuJ$7y0zy?hVUkKEOjiYjMQS+VNL52PMa?FhtCkb4R2LAgRo4<;uWljSuI?v%Q2mzh z8MT{mkJ?MvsG0~5sE-H_t1k&#)p5cT$cQ%OvGnEoa^hF&s|dH~Eri$W>j`)3my|>A z)y?o$>ZIT_f;| z(hi-ghwE{Ax~}Dk+w`6K75$MurcY>>6>LRW309Uh#458UTU)IMtq-lwt-o78I3gWI zj?s>4$GMJ0j(W#Mj;kEo9QQbOI)3AL*YTC(J3psim|u+FK)*u2D!-+EoBg)>-Rbv; z-&20?`}_Nc`N#MV^e^-;^`Gc}uK%t6_xpeDbUM?Wi<~z(?{WUZ`8(&yfWUx=fVhB+ zfVzP70hb5d7;tyMo`5$34h6IXd>!!bKxbgDz}Ud_z@otMflC4#0yhV44ZJh(>A*Jv z4+XXaejWJlAZJkjptPWZpiw~=2JH!YBj`|2OVHOr{|@#K4hxP6P7A&@`2OGzy9IWO z=oZ&4qubzatGiv??GGU;WN^sXkVzqPLY9OyglrDk8gfU-!y&&8`Ac`d?hV~Hci-Cm z&hERr|0UEB+9R}YXmV&y=&;c8(8-~5LYIZE3%xA#(a;w|n?sL=9uGa)Bd|w!j{!aM zd#vnnbB}v_Jl5lx9xwO!d(ZfunLUT}EbBSB=iHu4dp7j^WzXM-MTJ#|T@|(`?2lnb z!~P!j?_Q&NRrQ+DYhkbYUN7|exYu8MeH*UBlf!evhlh^~pB`QtzA}7c_?6+?!gqxK zI=m_Tqwr(lC&FD3Q4yIDLn6u|CP&PTSQ>F@#B~w3MLZbM9C0+_c*M!bz{rTm0g;0u zizCNGJ{I{*lz&uMR7}*ssPR$fL@kI~6?IY6)ls)Z-5d2-)H6{pNBs~T5FH*pAbL=A zarCn2_0f+;e-wSZcS7&H-t&9!?7g@54}Hq}%SUk{^g)PgDM7X81(Z&2QzfWkcKZGCs~Yo*9@Kotc_B zHgj3#jhT06{x)-e=3ldVW))^l$T~M`an?my*Jj<7^=#JRY?U37osm5?dt>&E*|%pu zlKouvYuO)Wf0NTUXGG3~oaH&U(3??|z@v*rqemz|zmGUxt~MK;-JSkEL~T+}>HytO zC+d7%tSj|wU8gtcoAfREl-18Fw$8O)vp#nCIRYIaj$V#5$4jwS!R;4f{~7zQxQMvE zaj|g;aVc>*aYN(A#x=w}8uw(}uDBQC!{Za;Q{uDY?@F)|oC!S=dL=|A^i7CO7?e<% zFh8L#`HdeeMk}SdqkD7IQ1yWNl@8XiIz<=hA$q)?r0$?`ZQ0eKmEgrKIiVuV24i9CHiZtr`6Xgu!^i{)_Uto z$=Q0+`js4s{mOdYddZS8SI+i=H>|g;CXP+k0qc-W_k`A4o*36X_q^LdgNoH5f4UUi zzEn>_xBpzP)64X^dX}!yH|R1n{#ZR(m+K0>UEi#4)f03BCGO6s7p|gJBI8~fqh29n z-e5I?F|SNbV$7SWW-#W}suhfRYZ&n^)N}O|+VmbhQ~wExyN>a18>8O?jD0^>zfiwp z^m|Ud!06Yg-cj!{@*QH_JEFc~-1`Tu^F2~|v7V#v(xZ`$%k_2o3P!t2^alM$y-^Q9 zV(-=$>d*Dh^c{MH9;pxOhxK|rQ6FX0iqq%oVR|UMtuj*Yr-HE}f>bvZreYc6`>Fma zS7k8vXJe6!QR7v)s#Mi#oW4m-ROhP&*iv<>K}}YFVnn=DU8b&~2VJeUs2dp_Z&P=w zyVS4LW9kX@BsRrfwO_reUQ=(Y*Y!5_min{$Ont77s(-3?)we1@T}hAJjCFApmfBX; zixIY`x`Cc~vx>wb7@&5jKI(QAqwc0(+^hPkJ5;iI2;1Tjm7*S21JzC}jNhm{^=p-> z9#{G5DSFSNY7iDgv3i!?{5&K0F8b5&Rf+locF1luO1-E?tCv+NBl%eMimFm?GRE&y z6Vw}6C{1dbdS9KRK2X!KdS<(MJ-c*Rg2Xz zwN!nf>ebh3o%%+tR~M;&sf*PQ>SyZTSQOV{@qC2s`KdmFjqsWNvu@EJ>pl8K?2di< zHQlIRzz%s;zocKm9y+d8slTb!>hD-l_hECisg3G8y-+XG=V5Cs(DU`Bdb6(9*XpbF zFZD0Kpa#`ZgoR8CI0oJbpo(J?huwj%(G|?`e13QlWl3 z-EoJ?^4sW+`}w5tS3~_qxam&S+b_i(4^(k}Vy{a6L288Kb$7g*8s*sHj)$nxj&gUr zyH2o@-0@IVq`!8@d#X@<#2pV)!B}B-o_$n^e#4HV60-CTcbxKL>6tXzBEG~OcMz|3 z$0@%qaL4_L_jSjeNX<9ycmQ&4Y(l>v?D5`6gPms(ZRX~W4n{Jcbkn;bm3O-1Asyw^ z^nDKxbtw04b*JfpBwy@~_avVNcRUO!U*L}SqVLUg$HVE3Q{3?g(vNq?BZ-f8$D@c3 zb;qNL=egs(iKn~ceJH29-KjSvb<_J1Q#_#_9aM{yjiW@Ok(MIKWk^{AnyHp2tsq>8 zlo!yGRzuB4m&q{?iX3y%Z1&MUA7air?(@y7p4=9Zr+u7?-dM@=Jm(WRU&Q%3s2z7z zL0L=>YoI4CA-)RTS?8tIn5RjZrR;I&(xvpbI#aK8q!ryH^a{cYNxOs;rNkScui<(v zv8D9UI?_~|CoeEpx=uB@%lj|v^2)j{uPpBJN^O@{CQ;5+)VP78UH*E`$D)m=a=wZh zcJNH7abCJ`u>mfuMQSjTI4*<>OAQa#qTz*mD>&9eUCi|wbbXbZ?`z4YoGYuyL2|4| z_cx%CYDp{HTgu&W+%0WUXG$Th8E48Wt+0l81N@h?Q<1hRj?#LP);DJ-=0#X=-WNTJ94WUO*_%-oUw(BMzE8x0I1f;tlX54*LHIhfcq1 z15epNDMeaEqNPwP4bjp@SpDXm90!4={mh-3aC#GzXC?t#YR5rO}W-G=H+m02*@Wk zmlPc-E0BX4dg!oDGVJ@DDYV>TxF+qm#-!?y={TSANIh0lKapu$XT+hWa*&n+>MZ(0 zB%pJj5MHd|zGYmKJ{5;VtmA0&Mr1mTcxS0tO#YH$9a80K!EBx=wOwrFOj=pmd@a3b z5$$WYn@t&LWvXiHMiu{4z}Oitu%>a(ewBL$ehFRhstPj5BmPryjQk=<|4)v-co^Cr zv0hs^K8jv!!5VC7{}Ox!zTKxLr%gspbW!* zGMuWP)p{oM$12K-Q;}x_mV2@?VmFPkFoAiF%GeX!S7U@{wwuauJRoD9B1P5UPawjse$?d zH4WtHL8=-I$9~t8TkDxBR=`ZpK>toy#kUw^FtnNX5dyrOUpSQ!0 zlPW@>tJ*(w990vo1IXxbm1>PuS&sgykTr`U>xAm3Co_JaM?KK(Dh>m{kn%5 zBl^mrRJ0=l{x4P;egWvKt5q@Wk&WCZ>Px8WH>wXP2IcOf#08Vwn8;Dk17z4}|IAv* z^M1)J`BeL1M2UXIgw{gD;}7inl|u9()Mzf!OvZWv*+qZ zDk-riwWq{qPaRcTyP`p-FO)E8VeQ&F9lLPh%GEl`gdvMo)vwgf^Vifa)M~}j#kK0+ ztm>^$-Dz=W*OL3f%k%!INICZ0a=AzSd4l{bzO6T02g}p{d>Jjo-F<#lKw(J#aYK)Jz2}j(3v_*XCwcbvqYVY zA304A@}`oS$(gLjdF~WivdQU$`p%TWE{D)rwYk#P%9Ac>eG1Lmf#gmLdGPo z9cr^$0&h!Hy8Z=aIbiN?C6^=W9sNtn)IoX9_ZMA1QrSkCMv^Hb35BJHd;~A%? z+l7YicxGqbo}Ttuv`t4^Up{#_dHNkscX@}0E2rP_e5X6k#_7}V_-&VWboGqice=xu z&z_bz?HTkj3rTp7{zhLj=xdG^qnMK&0v5d~hEVK0&B}&HZ{TxgFU0^ICxFU60=$2l zkRQ>u7Aq*mW62C&yGJM4Z6Z%ABMm-U#`)8)XzoWt=>&8fJ2ik^BGFq>Jo@8;~b0#NvVser*g{NI|_s|9#n$*uzY8n3|#VmCmR?a7# zuGrUp!81Im<$1mB=h?d`Xs73(JwU5>s5FEpzwmU(e0*wspA;$KV?r(cO9yG=$<-E_tN1yLPzQ-9j$xoKDsaK&;4cm_Kuf~m1na9 zespw zSSa5yD@oI7%&!K~$I=fFr`C5F4@9KDT z+hX;;PSA<0M*bSP%4ZHyz)a6b4l7uxtYiKFR|YaGONTER^x1X2=JAuoA& zL5Ctq1xQX2KF<>M6?2n6AY((+aP-hIR%1(;`z}SUmg7rYCM&l15?87Rkis$~*F_`N z>r#9lqp@7Sq+hMpWAs?Om}_Z;3(RVCr5=ZmeZ2Y?>%kM$1YL!f^h4Ev=2)*bXjVE{ zzn+A~o5GB7Dl_A0s)n`fbMy?x{Lk@-PBv@Yv(-jD2RUJVn$^pT)kUmPpNE88!W!#T z);FiA>3Y7gJI`Tm{%3l@B4lU=>&@ru#q_gHdI^@#W$IE^&zISD9&4wwY+H}sHCxS5 zt@O2BdaZ6ybM-p)7uKaWux5QBw(!qb)4qhg7P7*58LQe?$ZjsyIj>_AhxmgehaJb+x2a%cHW_0Vm)=Tek&#$78+}^Al_m$Obc4ytidiXthhrUr+-K++FtJ+xQ--+ZtN>6_b%O{NaKn&LR6ZDaXRRZhHuc&LVx14CQ zk*v3h_4He`%xG-05cJ%9R{BTLb6#cL@EKN}pJVm=ajd`>>0QCB(*DY<(7LU7R;&}z z;#-){T+h1yR@TtZQ(x$3SVP~dpJhG#1r^}2^7S8B7r$NaM*IGqmEq@A9P8uptcu@; zm3AvVwwAtI$~yTD){ytGMt&Fe*Nd!D2C9AfCH*oz@DKkeo zF05PC;2gWKmJ}S;)YmpR%glYwGD8e5^Ck-@bLUj%&S~AMrMYFJ#{_W9$sJs7AM^5p zE4;V(RW7JqV~rc{9A}qhoI8hcb`H*Q4NF%ns)Y95f6J6Mjn zWkbrG(|l4D<+(4HI;YzYnBM6D)7>?hZr8+r`kJMbWxA=4bB4*k+l+-v*DPGO^86Kb z8-r#nT3WZJZtc>w&KZl>)UL1Vc5cT-=edStm!o{JbC$V1c$Sxq&dFIaBX`!?TJ zyVl(fwRQFl3p;LDWUAt|AT8_TzJhI2(Mf7UjFiqn+#QjMsI_ zc%8d4>)egH&NS*eyHPioe7kMvXt$sZ-bUSEH|m8Q7o8XSHtG=PMdtS4i@XeV)=H!1 zb*nWO+;LMX6Yp8uk(VKa)_R`iPHXZv@esE}xKHfTo0H&Lse=0kQx6jlsI7CSwMECA zI8D0zZi~Hz$PY1HJ1<|Z=lAFk26OIQCx?P=>%0{p?!C95=eo`nCpCu9Zof5V0G*=~G zal@Hp5^*P!sGUq=MoM!=+ubl{w5^JAMjPqP%PB6fub1XnW2OWztY5j(NK$U-+PalX z7uK(+UnPah4ZmPreFGY2)#4Qka2Fa{n4v9Na2Cpny7L=MnqHnXYnHM<-jG5K$(y2w zIqkUY&DE1)@R@lSEt{8{n;%rSwgIKlP`AjhY~7lAsY+hX=u-F5XrR2DQaibu1$lY7 zh30y0UXeNG<(Okm&gjB`+BIwHH>_K2UmsG8z;kZO<|YjeGNeWI8&bac32;i0{S0<|NenINxhVt_IuJylir3TI6$| z6v5|I%3)5WpgH+DhA$k8gN)?T_zQxJyi3fTW^^|@?U*cl#crA{LisuNV4d&gUw%$$ zkm*1^ciY01pXpi@_o= z53rioZHgOJ90ld>^HCg!O8LEB+K%@uJY|aV{XbT-k{8OdA~&K{ZjMb!+@Q< zfcd@q-3;5C!2CYg6|`A7g4uVc1AH7j-BEBo|X~JKb(2nm4eAG*~DGv!B0(Y3u zj{AmP(d{(12X5nDJAPf@mBcrh(2idhxDm?nz%_&`OlZeF;rYVI>LCbZ+_0Ykm?f`F`mw15N?+Hv3TOnU!-sDPdUcADS-KVIx}Ilm`7 zVM06Za<)P_<~&09kqPa%Cp;)MaWBfJWz7=Li$pao?~jx=)%e<^PNxVQUiSU?+OonISc0Y)RuiN3BIm5t?|ojEj1+ ztQO#n{hn~BjF)}j){-XOdXIRr$tPWZ%NTXUjIT$`)t)AOPjj`W zp{zF)q?&uz8%i%jX*HByLQ%)f_;*|=>RS{4*5vuEO)+U!8u}qaUuo!vOm1J9e7-XA z+ZaE!SrH*Llr;0Kn+-kHq)apPzLFbtk=msTh25<3^bOX#gv-pcmO0o-!>&o8FEjLi z8u~vC<)3y+Q=S{_G$xljm#Ys4bZ zeX)_`9Z%tQvAMUexys7FTs8DYLuoXWMnmy8ltYH%Zz#cH1L~L@}@5<1N|unb`D;7g%8q3GsgpN zyv1=JcnRzRPgC|^fk&ywL!65?xE*W**ST>eN5LldxwPpT?>$;QE__i}n(!JEUTwnj zO}N;EhIh)e1m0n3T!`#b(0CtH{mrVyxL7U&eg>xo@K&|2vJ8~%eoo6 z-@@-}b-x=8W(D0XD{dhz0O_2sRdT59S`^{u&Y zymQ|0V2|&8?K`Mti{V2{`z_{Z+ShXz+z~#BUNzhzd>k&@d^;vbD6Jv~a8ydvDBOh7 z*uJU#DY?pDntkwu?eCKAEb`&0jeV55+xdI$>-y-v!?gX6)x9GJ>hMK7I);)S_dLav z-$((qwvR}F=W55jKQ;E>%fnC2x$AVE+Ix?tpZ<9IA1kMwkMP&GulwfLz7uYVJT$^n z)8ck7L>}IP+GMUbbKK#T2jAOG+*8^!F8m)5Oj%8jb+=(BdG^iFw?_Z(OTe2KKlU%QMh5Pi_(9(_*J)pDP& zbfJMhYX90$jZTm|&Dl4mbfVGRbYf@8%cK>V?@0BOq%(5Pcp=w2|JhpNY*#v``)|&8 zXXmVcow;+?ByOT_-Oe_Od9Jtb{IOg-*M2mI22ZM=dh|T|Kc{cs(|)x54Y=RZe!?D+ zB@W!Z9L+;JG46DXI6so<|3MpB>`&2HXkszT&a?!6;aJPbczb2%d=%DmI^Nq1Z0J7N z(4|}-!!L`SAXV7Y)%=Rti!q(OCNtQ}GYqTx0<7${?ADlum3j;N5yYNeYxap=iaq^2 z_UkTwTi98!7puFG_uX$|Kg_$lOE0$dJ=oTtvX4M)>qpt?^8M=fNMFs{g$~)%0$p}JISjwF;a3R!nvdVSmbr*OyKPpm(?ez? z-Qn0qN_PoA2YkF1`}sP4LCjI^r|hyzs+amXzaX<$Y5=@?6c4=cH^}fe$ne)`_-mQ{ zMix7W-s9c_>LfqQa9_T%#2ysG{bmtzc< zV+@!387@Z{8BajQ7pVlp@mRy_Si|Kw!{vU4%khTG@rKJuZvI}SuVQbza5;jR^I@5f zslR4?d6wVmjPt#}h(TZ5zw}4b*Qbx=7u@mlT=N_Uen05zwCmD(rvBUfwx!y?t5eVR zOG??}`6a8Q+kJl9lI-95q%(hWlIA3(Cxv)_>71YSml&Ja)BUp&HYTh}SdwUU{K%uz zFJS@al5;v=9U5x?;!nmOa{v0r_p*Njz8KKt{YAtc>-f3Lye(-?Y-Rtp{xka>>!$|w#bn11>tkT&k3 ~~aiE3a2X*q7lEd@Jgqu#BD~dW3|O2R8;k9=t<- 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/fonts/DMSans-Medium.ttf b/openwork-memos-integration/apps/desktop/public/fonts/DMSans-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4deeae0e4827fc7deaf317e64664fa28dd153df2 GIT binary patch literal 56376 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 literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/public/fonts/DMSans-Regular.ttf b/openwork-memos-integration/apps/desktop/public/fonts/DMSans-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b25fc0cc1cf3bd9cdd22e5a37f505f4d9fec474b GIT binary patch literal 56344 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 + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + + diff --git a/openwork-memos-integration/apps/desktop/resources/icon.png b/openwork-memos-integration/apps/desktop/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9e0efee4e78685881086124546bde887fc07aa75 GIT binary patch literal 26734 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 C6@T9V literal 0 HcmV?d00001 diff --git a/openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh b/openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh new file mode 100755 index 000000000..bb15523ca --- /dev/null +++ b/openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Run desktop app with LOCAL UI (Vite hot reload) + PRODUCTION API +# UI: localhost:5173 | API: lite.accomplish.ai +ACCOMPLISH_UI_URL=http://localhost:3000 ACCOMPLISH_API_URL=https://lite.accomplish.ai pnpm dev diff --git a/openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh b/openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh new file mode 100755 index 000000000..f4e248d15 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Run desktop app with LOCAL UI (Vite hot reload) + STAGING API +# UI: localhost:5173 | API: lite-staging.accomplish.ai +ACCOMPLISH_UI_URL=http://localhost:3000 ACCOMPLISH_API_URL=https://lite-staging.accomplish.ai pnpm dev diff --git a/openwork-memos-integration/apps/desktop/run_prod.sh b/openwork-memos-integration/apps/desktop/run_prod.sh new file mode 100755 index 000000000..89e165e00 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/run_prod.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Run desktop app with PRODUCTION UI + PRODUCTION API +# UI: lite.accomplish.ai | API: lite.accomplish.ai +# This builds an unpacked app and runs it (no hot reload) + +set -e + +echo "Building unpacked app for production..." +pnpm -F @accomplish/desktop build:unpack + +echo "Launching app with production configuration..." +ACCOMPLISH_UI_URL=https://lite.accomplish.ai \ +ACCOMPLISH_API_URL=https://lite.accomplish.ai \ +open apps/desktop/release/mac-arm64/Accomplish.app diff --git a/openwork-memos-integration/apps/desktop/run_staging.sh b/openwork-memos-integration/apps/desktop/run_staging.sh new file mode 100755 index 000000000..d83f770f3 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/run_staging.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Run desktop app with STAGING UI + STAGING API +# UI: lite-staging.accomplish.ai | API: lite-staging.accomplish.ai +# This builds an unpacked app and runs it (no hot reload) + +set -e + +echo "Building unpacked app for staging..." +pnpm -F @accomplish/desktop build:unpack + +echo "Launching app with staging configuration..." +ACCOMPLISH_UI_URL=https://lite-staging.accomplish.ai \ +ACCOMPLISH_API_URL=https://lite-staging.accomplish.ai \ +open apps/desktop/release/mac-arm64/Accomplish.app diff --git a/openwork-memos-integration/apps/desktop/scripts/after-pack.cjs b/openwork-memos-integration/apps/desktop/scripts/after-pack.cjs new file mode 100644 index 000000000..bbaaaf1b2 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/scripts/after-pack.cjs @@ -0,0 +1,256 @@ +/** + * Electron-builder afterPack hook to copy architecture-specific Node.js binaries. + * + * This hook runs after packing but before creating distributable formats. + * It copies the correct Node.js binary based on the target platform and architecture. + * + * @see https://www.electron.build/configuration/configuration#afterpack + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const NODE_VERSION = '20.18.1'; + +/** + * Map electron-builder arch number to string + * @see https://github.com/electron-userland/electron-builder/blob/master/packages/builder-util/src/arch.ts + */ +const ARCH_MAP = { + 0: 'ia32', // Arch.ia32 + 1: 'x64', // Arch.x64 + 2: 'armv7l', // Arch.armv7l + 3: 'arm64', // Arch.arm64 + 4: 'universal', // Arch.universal (macOS only) +}; + +/** + * Map electron-builder platform name to Node.js platform name + */ +const PLATFORM_MAP = { + mac: 'darwin', + windows: 'win32', + linux: 'linux', +}; + +/** + * Get the Node.js directory name based on platform + */ +function getNodeDirName(platform, arch) { + if (platform === 'win32') { + return `node-v${NODE_VERSION}-win-${arch}`; + } + return `node-v${NODE_VERSION}-${platform}-${arch}`; +} + +/** + * After-pack hook to copy architecture-specific Node.js binaries + * + * For universal macOS builds, we need to include BOTH x64 and arm64 Node.js + * binaries in EACH architecture's build. This is because electron-builder's + * universal app merger requires identical file structures in both builds. + * At runtime, the app uses process.arch to select the correct binary. + * + * @param {Object} context - electron-builder context + * @param {Object} context.packager - Packager instance + * @param {Object} context.packager.platform - Platform info + * @param {string} context.packager.platform.name - 'mac', 'linux', 'windows' + * @param {number} context.arch - Architecture number (0=ia32, 1=x64, 3=arm64, 4=universal) + * @param {string} context.appOutDir - Output directory for the app + */ +exports.default = async function afterPack(context) { + const { packager, arch, appOutDir } = context; + const platformName = packager.platform.name; + + const archName = ARCH_MAP[arch] || 'x64'; + const nodePlatform = PLATFORM_MAP[platformName] || platformName; + + console.log(`\n[after-pack] Platform: ${platformName}, Arch: ${archName}`); + + // Detect universal build by checking if output dir contains 'universal' + // For universal builds, appOutDir is like 'release/mac-universal-x64-temp' or 'release/mac-universal-arm64-temp' + const isUniversalBuild = appOutDir.includes('universal'); + + // For macOS universal builds, we need BOTH architectures in EACH build + // so that electron-builder can merge them (it requires identical file structures) + if (platformName === 'mac' && isUniversalBuild) { + console.log('[after-pack] macOS universal build - copying both x64 and arm64 Node.js binaries'); + await copyNodeBinary(context, nodePlatform, 'x64'); + await copyNodeBinary(context, nodePlatform, 'arm64'); + await resignMacApp(context); + return; + } + + // For single-arch builds, just copy the target architecture + await copyNodeBinary(context, nodePlatform, archName); + + // Re-sign macOS apps after modifying the bundle + if (platformName === 'mac') { + await resignMacApp(context); + } +}; + +/** + * Copy Node.js binary for a specific platform/arch combination + */ +async function copyNodeBinary(context, platform, arch) { + const { packager, appOutDir } = context; + const platformName = packager.platform.name; + + const nodeDirName = getNodeDirName(platform, arch); + + // Source: resources/nodejs/-/node-v20.18.1--/ + const sourceDir = path.join( + __dirname, + '..', + 'resources', + 'nodejs', + `${platform}-${arch}`, + nodeDirName + ); + + // Check if source exists - fail the build if missing + if (!fs.existsSync(sourceDir)) { + const errorMsg = `[after-pack] ERROR: Node.js binary not found at ${sourceDir}\n` + + `Run "pnpm -F @accomplish/desktop download:nodejs" first to download the binaries.`; + console.error(errorMsg); + throw new Error(errorMsg); + } + + // Determine destination based on platform + let destDir; + if (platformName === 'mac') { + // For universal builds, we need to include the arch in the path + // macOS app bundle structure: .app/Contents/Resources/ + const appName = packager.appInfo.productFilename; + destDir = path.join(appOutDir, `${appName}.app`, 'Contents', 'Resources', 'nodejs', arch); + } else { + // Windows/Linux: /resources/ + destDir = path.join(appOutDir, 'resources', 'nodejs', arch); + } + + console.log(`[after-pack] Copying Node.js ${arch}: ${sourceDir} -> ${destDir}`); + + // Create destination directory + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + // Copy the entire Node.js directory, excluding unnecessary directories + try { + copyDirRecursive(sourceDir, destDir, destDir, NODEJS_EXCLUDE_DIRS); + } catch (err) { + console.error(`[after-pack] ERROR copying Node.js ${arch}:`, err.message); + throw err; + } + + // Make binaries executable on Unix + if (platformName !== 'windows') { + const binDir = path.join(destDir, 'bin'); + if (fs.existsSync(binDir)) { + const binaries = ['node', 'npm', 'npx']; + for (const binary of binaries) { + const binPath = path.join(binDir, binary); + if (fs.existsSync(binPath)) { + fs.chmodSync(binPath, 0o755); + } + } + } + } + + console.log(`[after-pack] Successfully copied Node.js ${arch} to ${destDir}`); +} + +/** + * Directories to exclude from Node.js bundle. + * - 'include': Contains C/C++ header files (~53MB) only needed for native module compilation, + * not required at runtime. This significantly reduces DMG size. + */ +const NODEJS_EXCLUDE_DIRS = ['include']; + +/** + * Recursively copy a directory + * @param {string} src - Source directory + * @param {string} dest - Destination directory + * @param {string} rootDest - Root destination for symlink validation (optional, defaults to dest) + * @param {string[]} excludeDirs - Directory names to skip (optional) + */ +function copyDirRecursive(src, dest, rootDest = dest, excludeDirs = []) { + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + // Skip excluded directories + if (excludeDirs.includes(entry.name)) { + console.log(`[after-pack] Skipping excluded directory: ${entry.name} (saves ~53MB)`); + continue; + } + if (!fs.existsSync(destPath)) { + fs.mkdirSync(destPath, { recursive: true }); + } + copyDirRecursive(srcPath, destPath, rootDest, excludeDirs); + } else if (entry.isSymbolicLink()) { + // Preserve symlinks (npm and npx are often symlinks to node) + const linkTarget = fs.readlinkSync(srcPath); + + // Security: Validate symlink doesn't escape the root destination directory + // Only allow relative symlinks that stay within the directory tree + if (path.isAbsolute(linkTarget)) { + console.warn(`[after-pack] Skipping absolute symlink: ${srcPath} -> ${linkTarget}`); + continue; + } + + // Check resolved path doesn't escape the ROOT destination (not current dest) + // e.g., bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js is valid + const resolvedPath = path.resolve(path.dirname(destPath), linkTarget); + if (!resolvedPath.startsWith(rootDest)) { + console.warn(`[after-pack] Skipping symlink that escapes directory: ${srcPath} -> ${linkTarget}`); + continue; + } + + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + fs.symlinkSync(linkTarget, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Re-sign macOS app after modifying the bundle. + * + * Adding Node.js binaries invalidates the original signature. + * We re-sign with ad-hoc signature (-) which allows the app to run + * on machines with Gatekeeper when downloaded from the internet. + * + * For production releases, this should be replaced with proper + * Developer ID signing via electron-builder's sign option. + */ +async function resignMacApp(context) { + const { appOutDir, packager } = context; + const appName = packager.appInfo.productFilename; + const appPath = path.join(appOutDir, `${appName}.app`); + + console.log(`[after-pack] Re-signing macOS app: ${appPath}`); + + try { + // Remove existing signature and re-sign with ad-hoc signature + // --force: replace existing signature + // --deep: sign all nested code (frameworks, helpers, etc.) + // --sign -: ad-hoc signature (no certificate required) + execSync(`codesign --force --deep --sign - "${appPath}"`, { + stdio: 'inherit', + }); + console.log('[after-pack] Successfully re-signed macOS app'); + } catch (err) { + console.error('[after-pack] Failed to re-sign macOS app:', err.message); + // Don't fail the build - unsigned apps still work locally + // and users can remove quarantine manually + } +} diff --git a/openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs b/openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs new file mode 100644 index 000000000..9325489ca --- /dev/null +++ b/openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs @@ -0,0 +1,205 @@ +/** + * Download Node.js standalone binaries for bundling with the Electron app. + * + * Downloads Node.js v20.18.1 for: + * - macOS x64 + * - macOS arm64 + * + * Usage: node scripts/download-nodejs.cjs + */ + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const crypto = require('crypto'); + +const NODE_VERSION = '20.18.1'; +const BASE_URL = `https://nodejs.org/dist/v${NODE_VERSION}`; + +const PLATFORMS = [ + { + name: 'darwin-x64', + file: `node-v${NODE_VERSION}-darwin-x64.tar.gz`, + extract: 'tar', + sha256: 'c5497dd17c8875b53712edaf99052f961013cedc203964583fc0cfc0aaf93581', + }, + { + name: 'darwin-arm64', + file: `node-v${NODE_VERSION}-darwin-arm64.tar.gz`, + extract: 'tar', + sha256: '9e92ce1032455a9cc419fe71e908b27ae477799371b45a0844eedb02279922a4', + }, +]; + +const RESOURCES_DIR = path.join(__dirname, '..', 'resources', 'nodejs'); + +/** + * Download a file from URL with progress reporting + */ +function downloadFile(url, destPath) { + return new Promise((resolve, reject) => { + console.log(`Downloading: ${url}`); + + const file = fs.createWriteStream(destPath); + + https.get(url, (response) => { + // Handle redirects + if (response.statusCode === 302 || response.statusCode === 301) { + file.close(); + fs.unlinkSync(destPath); + return downloadFile(response.headers.location, destPath).then(resolve).catch(reject); + } + + if (response.statusCode !== 200) { + file.close(); + fs.unlinkSync(destPath); + reject(new Error(`Failed to download: HTTP ${response.statusCode}`)); + return; + } + + const totalSize = parseInt(response.headers['content-length'], 10); + let downloadedSize = 0; + let lastPercent = 0; + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + const percent = Math.floor((downloadedSize / totalSize) * 100); + if (percent >= lastPercent + 10) { + process.stdout.write(` ${percent}%`); + lastPercent = percent; + } + }); + + response.pipe(file); + + file.on('finish', () => { + file.close(); + console.log(' Done'); + resolve(); + }); + }).on('error', (err) => { + file.close(); + fs.unlinkSync(destPath); + reject(err); + }); + }); +} + +/** + * Verify SHA256 checksum of a file + */ +function verifyChecksum(filePath, expectedHash) { + console.log(' Verifying checksum...'); + const fileBuffer = fs.readFileSync(filePath); + const hashSum = crypto.createHash('sha256'); + hashSum.update(fileBuffer); + const actualHash = hashSum.digest('hex'); + + if (actualHash !== expectedHash) { + throw new Error(`Checksum mismatch!\n Expected: ${expectedHash}\n Got: ${actualHash}`); + } + console.log(' Checksum verified'); +} + +/** + * Extract archive to destination + * Uses execFileSync with array arguments to avoid command injection + */ +function extractArchive(archivePath, destDir, type) { + console.log(` Extracting to ${destDir}...`); + + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + const { execFileSync } = require('child_process'); + + if (type === 'tar') { + // Use execFileSync with array args to avoid shell injection + execFileSync('tar', ['-xzf', archivePath, '-C', destDir], { stdio: 'inherit' }); + } else if (type === 'zip') { + if (process.platform === 'win32') { + // PowerShell requires -Command with a script block + execFileSync('powershell', [ + '-NoProfile', + '-Command', + `Expand-Archive -Path "${archivePath}" -DestinationPath "${destDir}" -Force` + ], { stdio: 'inherit' }); + } else { + execFileSync('unzip', ['-o', archivePath, '-d', destDir], { stdio: 'inherit' }); + } + } + + console.log(' Extraction complete'); +} + +/** + * Main download and setup function + */ +async function main() { + console.log(`\nNode.js v${NODE_VERSION} Binary Downloader`); + console.log('='.repeat(50)); + + // Create resources directory + if (!fs.existsSync(RESOURCES_DIR)) { + fs.mkdirSync(RESOURCES_DIR, { recursive: true }); + } + + // Create temp directory for downloads + const tempDir = path.join(RESOURCES_DIR, '.temp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + for (const platform of PLATFORMS) { + console.log(`\nProcessing ${platform.name}...`); + + const archivePath = path.join(tempDir, platform.file); + const destDir = path.join(RESOURCES_DIR, platform.name); + + // Check if already extracted + const extractedDir = path.join(destDir, platform.file.replace(/\.(tar\.gz|zip)$/, '')); + if (fs.existsSync(extractedDir)) { + console.log(` Already exists: ${extractedDir}`); + continue; + } + + // Download if not cached + if (!fs.existsSync(archivePath)) { + const url = `${BASE_URL}/${platform.file}`; + await downloadFile(url, archivePath); + } else { + console.log(` Using cached: ${archivePath}`); + } + + // Verify checksum + verifyChecksum(archivePath, platform.sha256); + + // Extract + extractArchive(archivePath, destDir, platform.extract); + } + + // Clean up temp directory + console.log('\nCleaning up temp files...'); + fs.rmSync(tempDir, { recursive: true, force: true }); + + console.log('\nAll Node.js binaries downloaded successfully!'); + console.log(`Location: ${RESOURCES_DIR}`); + + // List what was downloaded + console.log('\nDirectory structure:'); + for (const platform of PLATFORMS) { + const destDir = path.join(RESOURCES_DIR, platform.name); + if (fs.existsSync(destDir)) { + const contents = fs.readdirSync(destDir); + console.log(` ${platform.name}/`); + contents.forEach(item => console.log(` ${item}/`)); + } + } +} + +main().catch((err) => { + console.error('\nError:', err.message); + process.exit(1); +}); diff --git a/openwork-memos-integration/apps/desktop/scripts/package.cjs b/openwork-memos-integration/apps/desktop/scripts/package.cjs new file mode 100644 index 000000000..740517bd0 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/scripts/package.cjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +/** + * Custom packaging script for Electron app with pnpm workspaces. + * Temporarily removes workspace symlinks that cause electron-builder issues. + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const nodeModulesPath = path.join(__dirname, '..', 'node_modules'); +const accomplishPath = path.join(nodeModulesPath, '@accomplish'); + +// Save symlink target for restoration +let symlinkTarget = null; +const sharedPath = path.join(accomplishPath, 'shared'); + +try { + // Check if @accomplish/shared symlink exists + if (fs.existsSync(sharedPath)) { + const stats = fs.lstatSync(sharedPath); + if (stats.isSymbolicLink()) { + symlinkTarget = fs.readlinkSync(sharedPath); + console.log('Temporarily removing workspace symlink:', sharedPath); + fs.unlinkSync(sharedPath); + + // Remove empty @accomplish directory if it exists + try { + fs.rmdirSync(accomplishPath); + } catch { + // Directory not empty or doesn't exist, ignore + } + } + } + + // Get command line args (everything after 'node scripts/package.js') + const args = process.argv.slice(2).join(' '); + // Use npx to run electron-builder to ensure it's found in node_modules + const command = `npx electron-builder ${args}`; + + console.log('Running:', command); + execSync(command, { stdio: 'inherit', cwd: path.join(__dirname, '..') }); + +} finally { + // Restore the symlink + if (symlinkTarget) { + console.log('Restoring workspace symlink'); + + // Recreate @accomplish directory if needed + if (!fs.existsSync(accomplishPath)) { + fs.mkdirSync(accomplishPath, { recursive: true }); + } + + fs.symlinkSync(symlinkTarget, sharedPath); + } +} diff --git a/openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs b/openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs new file mode 100644 index 000000000..d0ed480ed --- /dev/null +++ b/openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs @@ -0,0 +1,46 @@ +/** + * Patches the Electron.app Info.plist to show "Openwork" instead of "Electron" + * in macOS Cmd+Tab and Dock during development. + */ +const fs = require('fs'); +const path = require('path'); + +const APP_NAME = 'Openwork'; + +// Only run on macOS +if (process.platform !== 'darwin') { + console.log('[patch-electron-name] Skipping on non-macOS platform'); + process.exit(0); +} + +const electronPath = path.join( + __dirname, + '../node_modules/electron/dist/Electron.app/Contents/Info.plist' +); + +if (!fs.existsSync(electronPath)) { + console.error('[patch-electron-name] Electron Info.plist not found:', electronPath); + process.exit(1); +} + +let plist = fs.readFileSync(electronPath, 'utf8'); + +// Check if already patched +if (plist.includes(`${APP_NAME}`)) { + console.log(`[patch-electron-name] Already patched to "${APP_NAME}"`); + process.exit(0); +} + +// Replace CFBundleDisplayName and CFBundleName +plist = plist.replace( + /CFBundleDisplayName<\/key>\s*[^<]*<\/string>/, + `CFBundleDisplayName\n\t${APP_NAME}` +); + +plist = plist.replace( + /CFBundleName<\/key>\s*[^<]*<\/string>/, + `CFBundleName\n\t${APP_NAME}` +); + +fs.writeFileSync(electronPath, plist); +console.log(`[patch-electron-name] Patched Electron.app to show "${APP_NAME}"`); diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md b/openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md new file mode 100644 index 000000000..548c27c0d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md @@ -0,0 +1,133 @@ +--- +name: ask-user-question +description: Ask users questions via the UI. Use when you need clarification, user preferences, or confirmation before proceeding. The user CANNOT see CLI output - this tool is the ONLY way to communicate with them. +--- + +# Ask User Question + +Use this MCP tool to ask users questions and get their responses. This is the **ONLY** way to communicate with the user - they cannot see CLI/terminal output. + +## Critical Rule + +The user **CANNOT** see your text output or CLI prompts! + +If you write "Let me ask you..." and then just output text - **THE USER WILL NOT SEE IT**. +You MUST call this tool to display a modal in the UI. + +## When to Use + +- Clarifying questions before starting ambiguous tasks +- Asking user preferences (e.g., "How would you like files organized?") +- Confirming actions before executing (especially destructive/irreversible ones) +- Getting approval for sensitive actions (financial, messaging, deletion, etc.) +- Any situation where you need user input to proceed + +## Parameters + +```json +{ + "questions": [{ + "question": "Your question to the user", + "header": "Short label (max 12 chars)", + "options": [ + { "label": "Option 1", "description": "What this does" }, + { "label": "Option 2", "description": "What this does" } + ], + "multiSelect": false + }] +} +``` + +- `question` (required): The question text to display +- `header` (optional): Short category label, shown as modal title (max 12 chars) +- `options` (optional): Array of selectable choices (2-4 recommended) +- `multiSelect` (optional): Allow selecting multiple options (default: false) + +**Custom text input:** To allow users to type their own response, include an option with label "Other" (case-insensitive). When selected, the UI shows a text input field. + +```json +{ "label": "Other", "description": "Type your own response" } +``` + +**Important:** When "Other" is selected, the response will be `User responded: [their text]` instead of `User selected: Other`. You must wait for and handle this text response - do NOT proceed as if they selected a predefined option. + +## Examples + +### Asking about organization preferences + +``` +AskUserQuestion({ + "questions": [{ + "question": "How would you like to organize your Downloads folder?", + "header": "Organize", + "options": [ + { "label": "By file type", "description": "Group into Documents, Images, Videos, etc." }, + { "label": "By date", "description": "Group by month/year" }, + { "label": "By project", "description": "You'll help me name project folders" } + ] + }] +}) +``` + +### Confirming a destructive action + +``` +AskUserQuestion({ + "questions": [{ + "question": "Delete these 15 duplicate files?", + "header": "Confirm", + "options": [ + { "label": "Delete all", "description": "Remove all 15 duplicates" }, + { "label": "Review first", "description": "Show me the list before deleting" }, + { "label": "Cancel", "description": "Don't delete anything" } + ] + }] +}) +``` + +### Simple yes/no confirmation + +``` +AskUserQuestion({ + "questions": [{ + "question": "Should I proceed with sending this email?", + "header": "Send email", + "options": [ + { "label": "Send", "description": "Send the email now" }, + { "label": "Cancel", "description": "Don't send" } + ] + }] +}) +``` + +## Response Format + +The tool returns the user's selection: +- `User selected: By file type` - Single selection +- `User selected: Option A, Option B` - Multiple selections (if multiSelect: true) +- `User responded: [custom text]` - If user typed a custom response +- `User declined to answer the question.` - If user dismissed the modal + +## Wrong vs Correct + +**WRONG** (user won't see this): +``` +I'll help organize your files. How would you like them organized? +- By type +- By date +- By project +``` + +**CORRECT** (user will see a modal): +``` +AskUserQuestion({ + "questions": [{ + "question": "How would you like your files organized?", + "options": [ + { "label": "By type" }, + { "label": "By date" }, + { "label": "By project" } + ] + }] +}) +``` diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/package-lock.json b/openwork-memos-integration/apps/desktop/skills/ask-user-question/package-lock.json new file mode 100644 index 000000000..dc7e45281 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/ask-user-question/package-lock.json @@ -0,0 +1,1650 @@ +{ + "name": "ask-user-question", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ask-user-question", + "version": "0.0.1", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json b/openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json new file mode 100644 index 000000000..12cebdc85 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json @@ -0,0 +1,17 @@ +{ + "name": "ask-user-question", + "version": "0.0.1", + "type": "module", + "imports": { + "@/*": "./src/*" + }, + "scripts": { + "start": "npx tsx src/index.ts", + "dev": "npx tsx --watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts b/openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts new file mode 100644 index 000000000..6d514ca81 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * AskUserQuestion MCP Server + * + * Exposes an `AskUserQuestion` tool that the agent calls to ask users + * questions via the UI. Communicates with Electron main process via HTTP. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; + +const QUESTION_API_PORT = process.env.QUESTION_API_PORT || '9227'; +const QUESTION_API_URL = `http://localhost:${QUESTION_API_PORT}/question`; + +interface QuestionOption { + label: string; + description?: string; +} + +interface AskUserQuestionInput { + questions: Array<{ + question: string; + header?: string; + options?: QuestionOption[]; + multiSelect?: boolean; + }>; +} + +const server = new Server( + { name: 'ask-user-question', version: '1.0.0' }, + { capabilities: { tools: {} } } +); + +// List available tools +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'AskUserQuestion', + description: + 'Ask the user a question and wait for their response. Use this for clarifications, confirmations before sensitive actions, or when you need user input to proceed. Returns the user\'s selected option(s) or custom text response.', + inputSchema: { + type: 'object', + properties: { + questions: { + type: 'array', + description: 'Array of questions to ask (typically just one)', + items: { + type: 'object', + properties: { + question: { + type: 'string', + description: 'The question to ask the user', + }, + header: { + type: 'string', + description: 'Short header/category for the question (max 12 chars)', + }, + options: { + type: 'array', + description: 'Available choices for the user (2-4 options)', + items: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Display text for this option', + }, + description: { + type: 'string', + description: 'Explanation of what this option means', + }, + }, + required: ['label'], + }, + }, + multiSelect: { + type: 'boolean', + description: 'Allow selecting multiple options', + default: false, + }, + }, + required: ['question'], + }, + minItems: 1, + maxItems: 4, + }, + }, + required: ['questions'], + }, + }, + ], +})); + +// Handle tool calls +server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + if (request.params.name !== 'AskUserQuestion') { + return { + content: [{ type: 'text', text: `Error: Unknown tool: ${request.params.name}` }], + isError: true, + }; + } + + const args = request.params.arguments as AskUserQuestionInput; + const { questions } = args; + + // Validate required fields + if (!questions || questions.length === 0) { + return { + content: [{ type: 'text', text: 'Error: At least one question is required' }], + isError: true, + }; + } + + const question = questions[0]; + if (!question.question) { + return { + content: [{ type: 'text', text: 'Error: Question text is required' }], + isError: true, + }; + } + + try { + // Call Electron main process HTTP endpoint + const response = await fetch(QUESTION_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + question: question.question, + header: question.header, + options: question.options, + multiSelect: question.multiSelect, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + content: [{ type: 'text', text: `Error: Question API returned ${response.status}: ${errorText}` }], + isError: true, + }; + } + + const result = (await response.json()) as { + answered: boolean; + selectedOptions?: string[]; + customText?: string; + denied?: boolean; + }; + + if (result.denied) { + return { + content: [{ type: 'text', text: 'User declined to answer the question.' }], + }; + } + + // Format response for the agent + if (result.selectedOptions && result.selectedOptions.length > 0) { + return { + content: [{ type: 'text', text: `User selected: ${result.selectedOptions.join(', ')}` }], + }; + } + + if (result.customText) { + return { + content: [{ type: 'text', text: `User responded: ${result.customText}` }], + }; + } + + return { + content: [{ type: 'text', text: 'User provided no response.' }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text', text: `Error: Failed to ask question: ${errorMessage}` }], + isError: true, + }; + } +}); + +// Start the MCP server +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('AskUserQuestion MCP Server started'); +} + +main().catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); +}); diff --git a/openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json b/openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json new file mode 100644 index 000000000..3bcec373a --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore b/openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore new file mode 100644 index 000000000..318b216dd --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore @@ -0,0 +1,4 @@ +# Browser profile data +profiles/ +tmp/ +node_modules/ diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md b/openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md new file mode 100644 index 000000000..ed0a0289d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md @@ -0,0 +1,211 @@ +--- +name: dev-browser +description: Browser automation with persistent page state. Use when users ask to navigate websites, fill forms, take screenshots, extract web data, test web apps, or automate browser workflows. Trigger phrases include "go to [url]", "click on", "fill out the form", "take a screenshot", "scrape", "automate", "test the website", "log into", or any browser interaction request. +--- + +# Dev Browser Skill + +Browser automation that maintains page state across script executions. Write small, focused scripts to accomplish tasks incrementally. Once you've proven out part of a workflow and there is repeated work to be done, you can write a script to do the repeated work in a single execution. + +## Choosing Your Approach + +- **Local/source-available sites**: Read the source code first to write selectors directly +- **Unknown page layouts**: Use `getAISnapshot()` to discover elements and `selectSnapshotRef()` to interact with them +- **Visual feedback**: Take screenshots to see what the user sees + +## Setup + +Two modes available. Ask the user if unclear which to use. + +### Standalone Mode (Default) + +Launches a new Chromium browser for fresh automation sessions. + +```bash +./skills/dev-browser/server.sh & +``` + +Add `--headless` flag if user requests it. **Wait for the `Ready` message before running scripts.** + +### Extension Mode + +Connects to user's existing Chrome browser. Use this when: + +- The user is already logged into sites and wants you to do things behind an authed experience that isn't local dev. +- The user asks you to use the extension + +**Important**: The core flow is still the same. You create named pages inside of their browser. + +**Start the relay server:** + +```bash +cd skills/dev-browser && npm i && npm run start-extension & +``` + +Wait for `Waiting for extension to connect...` followed by `Extension connected` in the console. To know that a client has connected and the browser is ready to be controlled. +**Workflow:** + +1. Scripts call `client.page("name")` just like the normal mode to create new pages / connect to existing ones. +2. Automation runs on the user's actual browser session + +If the extension hasn't connected yet, tell the user to launch and activate it. Download link: https://github.com/SawyerHood/dev-browser/releases + +## Writing Scripts + +> **Run all scripts from `skills/dev-browser/` directory.** The `@/` import alias requires this directory's config. + +Execute scripts inline using heredocs: + +```bash +cd skills/dev-browser && npx tsx <<'EOF' +import { connect, waitForPageLoad } from "@/client.js"; + +const client = await connect(); +// Create page with custom viewport size (optional) +const page = await client.page("example", { viewport: { width: 1920, height: 1080 } }); + +await page.goto("https://example.com"); +await waitForPageLoad(page); + +console.log({ title: await page.title(), url: page.url() }); +await client.disconnect(); +EOF +``` + +**Write to `tmp/` files only when** the script needs reuse, is complex, or user explicitly requests it. + +### Key Principles + +1. **Small scripts**: Each script does ONE thing (navigate, click, fill, check) +2. **Evaluate state**: Log/return state at the end to decide next steps +3. **Descriptive page names**: Use `"checkout"`, `"login"`, not `"main"` +4. **Disconnect to exit**: `await client.disconnect()` - pages persist on server +5. **Plain JS in evaluate**: `page.evaluate()` runs in browser - no TypeScript syntax + +## Workflow Loop + +Follow this pattern for complex tasks: + +1. **Write a script** to perform one action +2. **Run it** and observe the output +3. **Evaluate** - did it work? What's the current state? +4. **Decide** - is the task complete or do we need another script? +5. **Repeat** until task is done + +### No TypeScript in Browser Context + +Code passed to `page.evaluate()` runs in the browser, which doesn't understand TypeScript: + +```typescript +// ✅ Correct: plain JavaScript +const text = await page.evaluate(() => { + return document.body.innerText; +}); + +// ❌ Wrong: TypeScript syntax will fail at runtime +const text = await page.evaluate(() => { + const el: HTMLElement = document.body; // Type annotation breaks in browser! + return el.innerText; +}); +``` + +## Scraping Data + +For scraping large datasets, intercept and replay network requests rather than scrolling the DOM. See [references/scraping.md](references/scraping.md) for the complete guide covering request capture, schema discovery, and paginated API replay. + +## Client API + +```typescript +const client = await connect(); + +// Get or create named page (viewport only applies to new pages) +const page = await client.page("name"); +const pageWithSize = await client.page("name", { viewport: { width: 1920, height: 1080 } }); + +const pages = await client.list(); // List all page names +await client.close("name"); // Close a page +await client.disconnect(); // Disconnect (pages persist) + +// ARIA Snapshot methods +const snapshot = await client.getAISnapshot("name"); // Get accessibility tree +const element = await client.selectSnapshotRef("name", "e5"); // Get element by ref +``` + +The `page` object is a standard Playwright Page. + +## Waiting + +```typescript +import { waitForPageLoad } from "@/client.js"; + +await waitForPageLoad(page); // After navigation +await page.waitForSelector(".results"); // For specific elements +await page.waitForURL("**/success"); // For specific URL +``` + +## Inspecting Page State + +### Screenshots + +```typescript +await page.screenshot({ path: "tmp/screenshot.png" }); +await page.screenshot({ path: "tmp/full.png", fullPage: true }); +``` + +### ARIA Snapshot (Element Discovery) + +Use `getAISnapshot()` to discover page elements. Returns YAML-formatted accessibility tree: + +```yaml +- banner: + - link "Hacker News" [ref=e1] + - navigation: + - link "new" [ref=e2] +- main: + - list: + - listitem: + - link "Article Title" [ref=e8] + - link "328 comments" [ref=e9] +- contentinfo: + - textbox [ref=e10] + - /placeholder: "Search" +``` + +**Interpreting refs:** + +- `[ref=eN]` - Element reference for interaction (visible, clickable elements only) +- `[checked]`, `[disabled]`, `[expanded]` - Element states +- `[level=N]` - Heading level +- `/url:`, `/placeholder:` - Element properties + +**Interacting with refs:** + +```typescript +const snapshot = await client.getAISnapshot("hackernews"); +console.log(snapshot); // Find the ref you need + +const element = await client.selectSnapshotRef("hackernews", "e2"); +await element.click(); +``` + +## Error Recovery + +Page state persists after failures. Debug with: + +```bash +cd skills/dev-browser && npx tsx <<'EOF' +import { connect } from "@/client.js"; + +const client = await connect(); +const page = await client.page("hackernews"); + +await page.screenshot({ path: "tmp/debug.png" }); +console.log({ + url: page.url(), + title: await page.title(), + bodyText: await page.textContent("body").then((t) => t?.slice(0, 200)), +}); + +await client.disconnect(); +EOF +``` diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/bun.lock b/openwork-memos-integration/apps/desktop/skills/dev-browser/bun.lock new file mode 100644 index 000000000..350c6c95c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/bun.lock @@ -0,0 +1,443 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "dev-browser", + "dependencies": { + "express": "^4.21.0", + "playwright": "^1.49.0", + }, + "devDependencies": { + "@types/express": "^5.0.0", + "tsx": "^4.21.0", + "vitest": "^2.1.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.1", "", { "os": "android", "cpu": "arm64" }, "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.1", "", { "os": "android", "cpu": "x64" }, "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.1", "", { "os": "none", "cpu": "x64" }, "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="], + + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], + + "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="], + + "@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="], + + "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], + + "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], + + "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], + + "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="], + + "@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="], + + "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], + + "@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="], + + "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], + + "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], + + "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], + + "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="], + + "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], + + "tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], + + "vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], + + "vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + + "serve-static/send/http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "serve-static/send/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/package-lock.json b/openwork-memos-integration/apps/desktop/skills/dev-browser/package-lock.json new file mode 100644 index 000000000..cbb6e75da --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/package-lock.json @@ -0,0 +1,3006 @@ +{ + "name": "dev-browser", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dev-browser", + "version": "0.0.1", + "dependencies": { + "@hono/node-server": "^1.19.7", + "@hono/node-ws": "^1.2.0", + "express": "^4.21.0", + "hono": "^4.11.1", + "playwright": "npm:rebrowser-playwright@^1.52.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "vitest": "^2.1.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hono/node-ws": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.0.tgz", + "integrity": "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==", + "license": "MIT", + "dependencies": { + "ws": "^8.17.0" + }, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "@hono/node-server": "^1.19.2", + "hono": "^4.6.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", + "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "name": "rebrowser-playwright", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/rebrowser-playwright/-/rebrowser-playwright-1.52.0.tgz", + "integrity": "sha512-UjpqfwmF9+XtOuCCxGQ2ZlLeuSaSv//4Z6ZQgYPsJovz3d7nWodCd2hSRQigAswAUnsPmVwnQUpSn+TLKaKV+A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "npm:rebrowser-playwright-core@~1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "name": "rebrowser-playwright-core", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/rebrowser-playwright-core/-/rebrowser-playwright-core-1.52.0.tgz", + "integrity": "sha512-gjrvLNh0RX6B/tg6pWaPNGf+9+z1Jl2EyAh5MXD5xMa2lputGRZ9V2MJ/uofcC5Np3vSOJ3SdVSRqwteC0FjfQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/package.json b/openwork-memos-integration/apps/desktop/skills/dev-browser/package.json new file mode 100644 index 000000000..331435004 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/package.json @@ -0,0 +1,31 @@ +{ + "name": "dev-browser", + "version": "0.0.1", + "type": "module", + "imports": { + "@/*": "./src/*" + }, + "scripts": { + "start-server": "npx tsx scripts/start-server.ts", + "start-extension": "npx tsx scripts/start-relay.ts", + "dev": "npx tsx --watch src/index.ts", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@hono/node-server": "^1.19.7", + "@hono/node-ws": "^1.2.0", + "express": "^4.21.0", + "hono": "^4.11.1", + "playwright": "npm:rebrowser-playwright@^1.52.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "vitest": "^2.1.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.0.0" + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md b/openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md new file mode 100644 index 000000000..a6e9b3cba --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md @@ -0,0 +1,155 @@ +# Data Scraping Guide + +For large datasets (followers, posts, search results), **intercept and replay network requests** rather than scrolling and parsing the DOM. This is faster, more reliable, and handles pagination automatically. + +## Why Not Scroll? + +Scrolling is slow, unreliable, and wastes time. APIs return structured data with pagination built in. Always prefer API replay. + +## Start Small, Then Scale + +**Don't try to automate everything at once.** Work incrementally: + +1. **Capture one request** - verify you're intercepting the right endpoint +2. **Inspect one response** - understand the schema before writing extraction code +3. **Extract a few items** - make sure your parsing logic works +4. **Then scale up** - add pagination loop only after the basics work + +This prevents wasting time debugging a complex script when the issue is a simple path like `data.user.timeline` vs `data.user.result.timeline`. + +## Step-by-Step Workflow + +### 1. Capture Request Details + +First, intercept a request to understand URL structure and required headers: + +```typescript +import { connect, waitForPageLoad } from "@/client.js"; +import * as fs from "node:fs"; + +const client = await connect(); +const page = await client.page("site"); + +let capturedRequest = null; +page.on("request", (request) => { + const url = request.url(); + // Look for API endpoints (adjust pattern for your target site) + if (url.includes("/api/") || url.includes("/graphql/")) { + capturedRequest = { + url: url, + headers: request.headers(), + method: request.method(), + }; + fs.writeFileSync("tmp/request-details.json", JSON.stringify(capturedRequest, null, 2)); + console.log("Captured request:", url.substring(0, 80) + "..."); + } +}); + +await page.goto("https://example.com/profile"); +await waitForPageLoad(page); +await page.waitForTimeout(3000); + +await client.disconnect(); +``` + +### 2. Capture Response to Understand Schema + +Save a raw response to inspect the data structure: + +```typescript +page.on("response", async (response) => { + const url = response.url(); + if (url.includes("UserTweets") || url.includes("/api/data")) { + const json = await response.json(); + fs.writeFileSync("tmp/api-response.json", JSON.stringify(json, null, 2)); + console.log("Captured response"); + } +}); +``` + +Then analyze the structure to find: + +- Where the data array lives (e.g., `data.user.result.timeline.instructions[].entries`) +- Where pagination cursors are (e.g., `cursor-bottom` entries) +- What fields you need to extract + +### 3. Replay API with Pagination + +Once you understand the schema, replay requests directly: + +```typescript +import { connect } from "@/client.js"; +import * as fs from "node:fs"; + +const client = await connect(); +const page = await client.page("site"); + +const results = new Map(); // Use Map for deduplication +const headers = JSON.parse(fs.readFileSync("tmp/request-details.json", "utf8")).headers; +const baseUrl = "https://example.com/api/data"; + +let cursor = null; +let hasMore = true; + +while (hasMore) { + // Build URL with pagination cursor + const params = { count: 20 }; + if (cursor) params.cursor = cursor; + const url = `${baseUrl}?params=${encodeURIComponent(JSON.stringify(params))}`; + + // Execute fetch in browser context (has auth cookies/headers) + const response = await page.evaluate( + async ({ url, headers }) => { + const res = await fetch(url, { headers }); + return res.json(); + }, + { url, headers } + ); + + // Extract data and cursor (adjust paths for your API) + const entries = response?.data?.entries || []; + for (const entry of entries) { + if (entry.type === "cursor-bottom") { + cursor = entry.value; + } else if (entry.id && !results.has(entry.id)) { + results.set(entry.id, { + id: entry.id, + text: entry.content, + timestamp: entry.created_at, + }); + } + } + + console.log(`Fetched page, total: ${results.size}`); + + // Check stop conditions + if (!cursor || entries.length === 0) hasMore = false; + + // Rate limiting - be respectful + await new Promise((r) => setTimeout(r, 500)); +} + +// Export results +const data = Array.from(results.values()); +fs.writeFileSync("tmp/results.json", JSON.stringify(data, null, 2)); +console.log(`Saved ${data.length} items`); + +await client.disconnect(); +``` + +## Key Patterns + +| Pattern | Description | +| ----------------------- | ------------------------------------------------------ | +| `page.on('request')` | Capture outgoing request URL + headers | +| `page.on('response')` | Capture response data to understand schema | +| `page.evaluate(fetch)` | Replay requests in browser context (inherits auth) | +| `Map` for deduplication | APIs often return overlapping data across pages | +| Cursor-based pagination | Look for `cursor`, `next_token`, `offset` in responses | + +## Tips + +- **Extension mode**: `page.context().cookies()` doesn't work - capture auth headers from intercepted requests instead +- **Rate limiting**: Add 500ms+ delays between requests to avoid blocks +- **Stop conditions**: Check for empty results, missing cursor, or reaching a date/ID threshold +- **GraphQL APIs**: URL params often include `variables` and `features` JSON objects - capture and reuse them diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts new file mode 100644 index 000000000..ddbd23394 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts @@ -0,0 +1,33 @@ +/** + * Start the CDP relay server for Chrome extension mode + * + * Usage: npm run start-extension + */ + +import { serveRelay } from "@/relay.js"; + +// Accomplish uses port 9224 to avoid conflicts with Claude Code's dev-browser (9222) +const PORT = parseInt(process.env.PORT || "9224", 10); +const HOST = process.env.HOST || "127.0.0.1"; + +async function main() { + const server = await serveRelay({ + port: PORT, + host: HOST, + }); + + // Handle shutdown + const shutdown = async () => { + console.log("\nShutting down relay server..."); + await server.stop(); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((err) => { + console.error("Failed to start relay server:", err); + process.exit(1); +}); diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts new file mode 100644 index 000000000..88d3628f8 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts @@ -0,0 +1,172 @@ +import { serve } from "@/index.js"; +import { execSync } from "child_process"; +import { mkdirSync, existsSync, unlinkSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Use a user-writable location for tmp and profiles (app bundle is read-only when installed) +// On macOS: ~/Library/Application Support/Accomplish/dev-browser/ +// Fallback: system temp directory +function getDataDir(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + if (process.platform === "darwin") { + return join(homeDir, "Library", "Application Support", "Accomplish", "dev-browser"); + } else if (process.platform === "win32") { + return join(process.env.APPDATA || homeDir, "Accomplish", "dev-browser"); + } else { + // Linux or fallback + return join(homeDir, ".accomplish", "dev-browser"); + } +} + +const dataDir = getDataDir(); +const tmpDir = join(dataDir, "tmp"); +const profileDir = join(dataDir, "profiles"); + +// Create data directories if they don't exist +console.log(`Creating data directory: ${dataDir}`); +mkdirSync(tmpDir, { recursive: true }); +mkdirSync(profileDir, { recursive: true }); + +// Accomplish uses ports 9224/9225 to avoid conflicts with Claude Code's dev-browser (9222/9223) +const ACCOMPLISH_HTTP_PORT = 9224; +const ACCOMPLISH_CDP_PORT = 9225; + +// Check if server is already running +console.log("Checking for existing servers..."); +try { + const res = await fetch(`http://localhost:${ACCOMPLISH_HTTP_PORT}`, { + signal: AbortSignal.timeout(1000), + }); + if (res.ok) { + console.log(`Server already running on port ${ACCOMPLISH_HTTP_PORT}`); + process.exit(0); + } +} catch { + // Server not running, continue to start +} + +// Clean up stale CDP port if HTTP server isn't running (crash recovery) +// This handles the case where Node crashed but Chrome is still running +try { + const pid = execSync(`lsof -ti:${ACCOMPLISH_CDP_PORT}`, { encoding: "utf-8" }).trim(); + if (pid) { + console.log(`Cleaning up stale Chrome process on CDP port ${ACCOMPLISH_CDP_PORT} (PID: ${pid})`); + execSync(`kill -9 ${pid}`); + } +} catch { + // No process on CDP port, which is expected +} + +// Clean up stale Chrome profile lock files (crash recovery) +// When Chrome crashes or is force-killed, it leaves behind SingletonLock files +// that prevent new instances from starting. Clean them up before launching. +// We have separate profile directories for system Chrome and Playwright Chromium. +const profileDirs = [ + join(profileDir, "chrome-profile"), + join(profileDir, "playwright-profile"), +]; +const staleLockFiles = ["SingletonLock", "SingletonSocket", "SingletonCookie"]; +for (const dir of profileDirs) { + for (const lockFile of staleLockFiles) { + const lockPath = join(dir, lockFile); + if (existsSync(lockPath)) { + try { + unlinkSync(lockPath); + console.log(`Cleaned up stale lock file: ${lockFile} in ${dir}`); + } catch (err) { + console.warn(`Failed to remove ${lockFile}:`, err); + } + } + } +} + +// Helper to install Playwright Chromium +function installPlaywrightChromium(): void { + console.log("\n========================================"); + console.log("Downloading browser (one-time setup)..."); + console.log("This may take 1-2 minutes."); + console.log("========================================\n"); + + const managers = [ + { name: "bun", command: "bunx playwright install chromium" }, + { name: "pnpm", command: "pnpm exec playwright install chromium" }, + { name: "npm", command: "npx playwright install chromium" }, + ]; + + let pm: { name: string; command: string } | null = null; + for (const manager of managers) { + try { + execSync(`which ${manager.name}`, { stdio: "ignore" }); + pm = manager; + break; + } catch { + // Package manager not found, try next + } + } + + if (!pm) { + throw new Error("No package manager found (tried bun, pnpm, npm)"); + } + + console.log(`Using ${pm.name} to install Playwright Chromium...`); + execSync(pm.command, { stdio: "inherit" }); // inherit shows download progress + console.log("\nBrowser installed successfully!\n"); +} + +// Start the server - tries system Chrome first, falls back to Playwright Chromium +console.log("Starting dev browser server..."); +const headless = process.env.HEADLESS === "true"; + +async function startServer(retry = false): Promise { + try { + const server = await serve({ + port: ACCOMPLISH_HTTP_PORT, + cdpPort: ACCOMPLISH_CDP_PORT, + headless, + profileDir, + useSystemChrome: true, // Try system Chrome first for faster startup + }); + + console.log(`Dev browser server started`); + console.log(` WebSocket: ${server.wsEndpoint}`); + console.log(` Tmp directory: ${tmpDir}`); + console.log(` Profile directory: ${profileDir}`); + console.log(`\nReady`); + console.log(`\nPress Ctrl+C to stop`); + + // Keep the process running + await new Promise(() => {}); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if error is about missing Playwright browsers + const isBrowserMissing = + errorMessage.includes("Executable doesn't exist") || + errorMessage.includes("browserType.launchPersistentContext") || + errorMessage.includes("npx playwright install") || + errorMessage.includes("run the install command"); + + if (isBrowserMissing && !retry) { + console.log("\nSystem Chrome not available, downloading Playwright Chromium..."); + try { + installPlaywrightChromium(); + // Retry with Playwright Chromium (useSystemChrome will fail again, but fallback will work) + await startServer(true); + return; + } catch (installError) { + console.error("Failed to install Playwright browsers:", installError); + console.log("You may need to run manually: npx playwright install chromium"); + process.exit(1); + } + } + + // If we've already retried or it's a different error, give up + console.error("Failed to start dev browser server:", error); + process.exit(1); + } +} + +await startServer(); diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh b/openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh new file mode 100755 index 000000000..25dbc81ee --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Change to the script directory +cd "$SCRIPT_DIR" + +# Parse command line arguments +HEADLESS=false +while [[ "$#" -gt 0 ]]; do + case $1 in + --headless) HEADLESS=true ;; + *) echo "Unknown parameter: $1"; exit 1 ;; + esac + shift +done + +# Check if node_modules exists - only install in dev mode if missing +if [ ! -d "node_modules" ]; then + echo "Dependencies not found. Installing..." + npm install +fi + +echo "Starting dev-browser server..." +export HEADLESS=$HEADLESS +npx tsx scripts/start-server.ts diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts new file mode 100644 index 000000000..4b4c4a028 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts @@ -0,0 +1,509 @@ +import { chromium, type Browser, type Page, type ElementHandle } from "playwright"; +import type { + GetPageRequest, + GetPageResponse, + ListPagesResponse, + ServerInfoResponse, + ViewportSize, +} from "./types"; +import { getSnapshotScript } from "./snapshot/browser-script"; + +/** + * Fetch with retry and exponential backoff for handling concurrent connection issues. + * This is necessary when multiple tasks try to connect to the dev-browser server simultaneously. + */ +async function fetchWithRetry( + url: string, + options?: RequestInit, + maxRetries = 3, + baseDelayMs = 100 +): Promise { + let lastError: Error | null = null; + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(url, options); + return res; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + // Only retry on connection errors (socket closed, etc.) + const isConnectionError = lastError.message.includes("fetch failed") || + lastError.message.includes("ECONNREFUSED") || + lastError.message.includes("socket") || + lastError.message.includes("UND_ERR"); + if (!isConnectionError || i >= maxRetries - 1) { + throw lastError; + } + // Exponential backoff with jitter + const delay = baseDelayMs * Math.pow(2, i) + Math.random() * 50; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastError || new Error("fetchWithRetry failed"); +} + +/** + * Options for waiting for page load + */ +export interface WaitForPageLoadOptions { + /** Maximum time to wait in ms (default: 10000) */ + timeout?: number; + /** How often to check page state in ms (default: 50) */ + pollInterval?: number; + /** Minimum time to wait even if page appears ready in ms (default: 100) */ + minimumWait?: number; + /** Wait for network to be idle (no pending requests) (default: true) */ + waitForNetworkIdle?: boolean; +} + +/** + * Result of waiting for page load + */ +export interface WaitForPageLoadResult { + /** Whether the page is considered loaded */ + success: boolean; + /** Document ready state when finished */ + readyState: string; + /** Number of pending network requests when finished */ + pendingRequests: number; + /** Time spent waiting in ms */ + waitTimeMs: number; + /** Whether timeout was reached */ + timedOut: boolean; +} + +interface PageLoadState { + documentReadyState: string; + documentLoading: boolean; + pendingRequests: PendingRequest[]; +} + +interface PendingRequest { + url: string; + loadingDurationMs: number; + resourceType: string; +} + +/** + * Wait for a page to finish loading using document.readyState and performance API. + * + * Uses browser-use's approach of: + * - Checking document.readyState for 'complete' + * - Monitoring pending network requests via Performance API + * - Filtering out ads, tracking, and non-critical resources + * - Graceful timeout handling (continues even if timeout reached) + */ +export async function waitForPageLoad( + page: Page, + options: WaitForPageLoadOptions = {} +): Promise { + const { + timeout = 10000, + pollInterval = 50, + minimumWait = 100, + waitForNetworkIdle = true, + } = options; + + const startTime = Date.now(); + let lastState: PageLoadState | null = null; + + // Wait minimum time first + if (minimumWait > 0) { + await new Promise((resolve) => setTimeout(resolve, minimumWait)); + } + + // Poll until ready or timeout + while (Date.now() - startTime < timeout) { + try { + lastState = await getPageLoadState(page); + + // Check if document is complete + const documentReady = lastState.documentReadyState === "complete"; + + // Check if network is idle (no pending critical requests) + const networkIdle = !waitForNetworkIdle || lastState.pendingRequests.length === 0; + + if (documentReady && networkIdle) { + return { + success: true, + readyState: lastState.documentReadyState, + pendingRequests: lastState.pendingRequests.length, + waitTimeMs: Date.now() - startTime, + timedOut: false, + }; + } + } catch { + // Page may be navigating, continue polling + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + // Timeout reached - return current state + return { + success: false, + readyState: lastState?.documentReadyState ?? "unknown", + pendingRequests: lastState?.pendingRequests.length ?? 0, + waitTimeMs: Date.now() - startTime, + timedOut: true, + }; +} + +/** + * Get the current page load state including document ready state and pending requests. + * Filters out ads, tracking, and non-critical resources that shouldn't block loading. + */ +async function getPageLoadState(page: Page): Promise { + const result = await page.evaluate(() => { + // Access browser globals via globalThis for TypeScript compatibility + /* eslint-disable @typescript-eslint/no-explicit-any */ + const g = globalThis as { document?: any; performance?: any }; + /* eslint-enable @typescript-eslint/no-explicit-any */ + const perf = g.performance!; + const doc = g.document!; + + const now = perf.now(); + const resources = perf.getEntriesByType("resource"); + const pending: Array<{ url: string; loadingDurationMs: number; resourceType: string }> = []; + + // Common ad/tracking domains and patterns to filter out + const adPatterns = [ + "doubleclick.net", + "googlesyndication.com", + "googletagmanager.com", + "google-analytics.com", + "facebook.net", + "connect.facebook.net", + "analytics", + "ads", + "tracking", + "pixel", + "hotjar.com", + "clarity.ms", + "mixpanel.com", + "segment.com", + "newrelic.com", + "nr-data.net", + "/tracker/", + "/collector/", + "/beacon/", + "/telemetry/", + "/log/", + "/events/", + "/track.", + "/metrics/", + ]; + + // Non-critical resource types + const nonCriticalTypes = ["img", "image", "icon", "font"]; + + for (const entry of resources) { + // Resources with responseEnd === 0 are still loading + if (entry.responseEnd === 0) { + const url = entry.name; + + // Filter out ads and tracking + const isAd = adPatterns.some((pattern) => url.includes(pattern)); + if (isAd) continue; + + // Filter out data: URLs and very long URLs + if (url.startsWith("data:") || url.length > 500) continue; + + const loadingDuration = now - entry.startTime; + + // Skip requests loading > 10 seconds (likely stuck/polling) + if (loadingDuration > 10000) continue; + + const resourceType = entry.initiatorType || "unknown"; + + // Filter out non-critical resources loading > 3 seconds + if (nonCriticalTypes.includes(resourceType) && loadingDuration > 3000) continue; + + // Filter out image URLs even if type is unknown + const isImageUrl = /\.(jpg|jpeg|png|gif|webp|svg|ico)(\?|$)/i.test(url); + if (isImageUrl && loadingDuration > 3000) continue; + + pending.push({ + url, + loadingDurationMs: Math.round(loadingDuration), + resourceType, + }); + } + } + + return { + documentReadyState: doc.readyState, + documentLoading: doc.readyState !== "complete", + pendingRequests: pending, + }; + }); + + return result; +} + +/** Server mode information */ +export interface ServerInfo { + wsEndpoint: string; + mode: "launch" | "extension"; + extensionConnected?: boolean; +} + +/** + * Options for creating or getting a page + */ +export interface PageOptions { + /** Viewport size for new pages */ + viewport?: ViewportSize; +} + +export interface DevBrowserClient { + page: (name: string, options?: PageOptions) => Promise; + list: () => Promise; + close: (name: string) => Promise; + disconnect: () => Promise; + /** + * Get AI-friendly ARIA snapshot for a page. + * Returns YAML format with refs like [ref=e1], [ref=e2]. + * Refs are stored on window.__devBrowserRefs for cross-connection persistence. + */ + getAISnapshot: (name: string) => Promise; + /** + * Get an element handle by its ref from the last getAISnapshot call. + * Refs persist across Playwright connections. + */ + selectSnapshotRef: (name: string, ref: string) => Promise; + /** + * Get server information including mode and extension connection status. + */ + getServerInfo: () => Promise; +} + +// Accomplish uses port 9224 to avoid conflicts with Claude Code's dev-browser (9222) +export async function connect(serverUrl = "http://localhost:9224"): Promise { + let browser: Browser | null = null; + let wsEndpoint: string | null = null; + let connectingPromise: Promise | null = null; + + async function ensureConnected(): Promise { + // Return existing connection if still active + if (browser && browser.isConnected()) { + return browser; + } + + // If already connecting, wait for that connection (prevents race condition) + if (connectingPromise) { + return connectingPromise; + } + + // Start new connection with mutex + connectingPromise = (async () => { + try { + // Fetch wsEndpoint from server (with retry for concurrent connections) + const res = await fetchWithRetry(serverUrl); + if (!res.ok) { + throw new Error(`Server returned ${res.status}: ${await res.text()}`); + } + const info = (await res.json()) as ServerInfoResponse; + wsEndpoint = info.wsEndpoint; + + // Connect to the browser via CDP + browser = await chromium.connectOverCDP(wsEndpoint); + return browser; + } finally { + connectingPromise = null; + } + })(); + + return connectingPromise; + } + + // Find page by CDP targetId - more reliable than JS globals + async function findPageByTargetId(b: Browser, targetId: string): Promise { + for (const context of b.contexts()) { + for (const page of context.pages()) { + let cdpSession; + try { + cdpSession = await context.newCDPSession(page); + const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); + if (targetInfo.targetId === targetId) { + return page; + } + } catch (err) { + // Only ignore "target closed" errors, log unexpected ones + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("Target closed") && !msg.includes("Session closed")) { + console.warn(`Unexpected error checking page target: ${msg}`); + } + } finally { + if (cdpSession) { + try { + await cdpSession.detach(); + } catch { + // Ignore detach errors - session may already be closed + } + } + } + } + } + return null; + } + + // Helper to get a page by name (used by multiple methods) + async function getPage(name: string, options?: PageOptions): Promise { + // Request the page from server (creates if doesn't exist) + // Use fetchWithRetry for concurrent connection resilience + const res = await fetchWithRetry(`${serverUrl}/pages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, viewport: options?.viewport } satisfies GetPageRequest), + }); + + if (!res.ok) { + throw new Error(`Failed to get page: ${await res.text()}`); + } + + const pageInfo = (await res.json()) as GetPageResponse & { url?: string }; + const { targetId } = pageInfo; + + // Connect to browser + const b = await ensureConnected(); + + // Check if we're in extension mode + const infoRes = await fetchWithRetry(serverUrl); + const info = (await infoRes.json()) as { mode?: string }; + const isExtensionMode = info.mode === "extension"; + + if (isExtensionMode) { + // In extension mode, DON'T use findPageByTargetId as it corrupts page state + // Instead, find page by URL or use the only available page + const allPages = b.contexts().flatMap((ctx) => ctx.pages()); + + if (allPages.length === 0) { + throw new Error(`No pages available in browser`); + } + + if (allPages.length === 1) { + return allPages[0]!; + } + + // Multiple pages - try to match by URL if available + if (pageInfo.url) { + const matchingPage = allPages.find((p) => p.url() === pageInfo.url); + if (matchingPage) { + return matchingPage; + } + } + + // Fall back to first page + if (!allPages[0]) { + throw new Error(`No pages available in browser`); + } + return allPages[0]; + } + + // In launch mode, use the original targetId-based lookup + const page = await findPageByTargetId(b, targetId); + if (!page) { + throw new Error(`Page "${name}" not found in browser contexts`); + } + + return page; + } + + return { + page: getPage, + + async list(): Promise { + const res = await fetchWithRetry(`${serverUrl}/pages`); + const data = (await res.json()) as ListPagesResponse; + return data.pages; + }, + + async close(name: string): Promise { + const res = await fetchWithRetry(`${serverUrl}/pages/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + + if (!res.ok) { + throw new Error(`Failed to close page: ${await res.text()}`); + } + }, + + async disconnect(): Promise { + // Just disconnect the CDP connection - pages persist on server + if (browser) { + await browser.close(); + browser = null; + } + }, + + async getAISnapshot(name: string): Promise { + // Get the page + const page = await getPage(name); + + // Inject the snapshot script and call getAISnapshot + const snapshotScript = getSnapshotScript(); + const snapshot = await page.evaluate((script: string) => { + // Inject script if not already present + // Note: page.evaluate runs in browser context where window exists + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + if (!w.__devBrowser_getAISnapshot) { + // eslint-disable-next-line no-eval + eval(script); + } + return w.__devBrowser_getAISnapshot(); + }, snapshotScript); + + return snapshot; + }, + + async selectSnapshotRef(name: string, ref: string): Promise { + // Get the page + const page = await getPage(name); + + // Find the element using the stored refs + const elementHandle = await page.evaluateHandle((refId: string) => { + // Note: page.evaluateHandle runs in browser context where globalThis is the window + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + const refs = w.__devBrowserRefs; + if (!refs) { + throw new Error("No snapshot refs found. Call getAISnapshot first."); + } + const element = refs[refId]; + if (!element) { + throw new Error( + `Ref "${refId}" not found. Available refs: ${Object.keys(refs).join(", ")}` + ); + } + return element; + }, ref); + + // Check if we got an element + const element = elementHandle.asElement(); + if (!element) { + await elementHandle.dispose(); + return null; + } + + return element; + }, + + async getServerInfo(): Promise { + const res = await fetchWithRetry(serverUrl); + if (!res.ok) { + throw new Error(`Server returned ${res.status}: ${await res.text()}`); + } + const info = (await res.json()) as { + wsEndpoint: string; + mode?: string; + extensionConnected?: boolean; + }; + return { + wsEndpoint: info.wsEndpoint, + mode: (info.mode as "launch" | "extension") ?? "launch", + extensionConnected: info.extensionConnected, + }; + }, + }; +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts new file mode 100644 index 000000000..204a1be6c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts @@ -0,0 +1,324 @@ +import express, { type Express, type Request, type Response } from "express"; +// Using rebrowser-playwright (via npm alias) for better anti-detection +// Rebrowser patches fix CDP-level detection leaks (Runtime.Enable) that stealth plugins can't fix +import { chromium, type BrowserContext, type Page } from "playwright"; +import { mkdirSync } from "fs"; +import { join } from "path"; +import type { Socket } from "net"; +import type { + ServeOptions, + GetPageRequest, + GetPageResponse, + ListPagesResponse, + ServerInfoResponse, +} from "./types"; + +export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse }; + +export interface DevBrowserServer { + wsEndpoint: string; + port: number; + stop: () => Promise; +} + +// Helper to retry fetch with exponential backoff +async function fetchWithRetry( + url: string, + maxRetries = 5, + delayMs = 500 +): Promise { + let lastError: Error | null = null; + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(url); + if (res.ok) return res; + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (i < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1))); + } + } + } + throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`); +} + +// Helper to add timeout to promises +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms) + ), + ]); +} + +export async function serve(options: ServeOptions = {}): Promise { + // Accomplish uses ports 9224/9225 to avoid conflicts with Claude Code's dev-browser (9222/9223) + const port = options.port ?? 9224; + const headless = options.headless ?? false; + const cdpPort = options.cdpPort ?? 9225; + const profileDir = options.profileDir; + const useSystemChrome = options.useSystemChrome ?? true; // Default to trying system Chrome + + // Validate port numbers + if (port < 1 || port > 65535) { + throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`); + } + if (cdpPort < 1 || cdpPort > 65535) { + throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`); + } + if (port === cdpPort) { + throw new Error("port and cdpPort must be different"); + } + + // Base profile directory + const baseProfileDir = profileDir ?? join(process.cwd(), ".browser-data"); + + let context: BrowserContext; + let usedSystemChrome = false; + + // Try system Chrome first if enabled (much faster - no download needed) + if (useSystemChrome) { + try { + console.log("Trying to use system Chrome..."); + // Use separate profile directory for system Chrome to avoid compatibility issues + const chromeUserDataDir = join(baseProfileDir, "chrome-profile"); + mkdirSync(chromeUserDataDir, { recursive: true }); + + context = await chromium.launchPersistentContext(chromeUserDataDir, { + headless, + channel: 'chrome', // Use system Chrome instead of Playwright's Chromium + ignoreDefaultArgs: ['--enable-automation'], // Remove automation flag + args: [ + `--remote-debugging-port=${cdpPort}`, + '--disable-blink-features=AutomationControlled', // Hide navigator.webdriver + ], + }); + usedSystemChrome = true; + console.log("Using system Chrome (fast startup!)"); + } catch (chromeError) { + console.log("System Chrome not available, falling back to Playwright Chromium..."); + // Fall through to Playwright Chromium below + } + } + + // Fall back to Playwright's bundled Chromium + if (!usedSystemChrome) { + // Use separate profile directory for Playwright Chromium to avoid compatibility issues + const playwrightUserDataDir = join(baseProfileDir, "playwright-profile"); + mkdirSync(playwrightUserDataDir, { recursive: true }); + + console.log("Launching browser with Playwright Chromium..."); + context = await chromium.launchPersistentContext(playwrightUserDataDir, { + headless, + ignoreDefaultArgs: ['--enable-automation'], // Remove automation flag + args: [ + `--remote-debugging-port=${cdpPort}`, + '--disable-blink-features=AutomationControlled', // Hide navigator.webdriver + ], + }); + console.log("Browser launched with Playwright Chromium"); + } + + console.log("Browser launched with persistent profile..."); + + // Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup) + const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`); + const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string }; + const wsEndpoint = cdpInfo.webSocketDebuggerUrl; + console.log(`CDP WebSocket endpoint: ${wsEndpoint}`); + + // Registry entry type for page tracking + interface PageEntry { + page: Page; + targetId: string; + } + + // Registry: name -> PageEntry + const registry = new Map(); + + // Helper to get CDP targetId for a page + async function getTargetId(page: Page): Promise { + const cdpSession = await context.newCDPSession(page); + try { + const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); + return targetInfo.targetId; + } finally { + await cdpSession.detach(); + } + } + + // Express server for page management + const app: Express = express(); + app.use(express.json()); + + // GET / - server info + app.get("/", (_req: Request, res: Response) => { + const response: ServerInfoResponse = { wsEndpoint }; + res.json(response); + }); + + // GET /pages - list all pages + app.get("/pages", (_req: Request, res: Response) => { + const response: ListPagesResponse = { + pages: Array.from(registry.keys()), + }; + res.json(response); + }); + + // POST /pages - get or create page + app.post("/pages", async (req: Request, res: Response) => { + const body = req.body as GetPageRequest; + const { name, viewport } = body; + + if (!name || typeof name !== "string") { + res.status(400).json({ error: "name is required and must be a string" }); + return; + } + + if (name.length === 0) { + res.status(400).json({ error: "name cannot be empty" }); + return; + } + + if (name.length > 256) { + res.status(400).json({ error: "name must be 256 characters or less" }); + return; + } + + // Check if page already exists + let entry = registry.get(name); + if (!entry) { + // Create new page in the persistent context (with timeout to prevent hangs) + const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s"); + + // Apply viewport if provided + if (viewport) { + await page.setViewportSize(viewport); + } + + const targetId = await getTargetId(page); + entry = { page, targetId }; + registry.set(name, entry); + + // Clean up registry when page is closed (e.g., user clicks X) + page.on("close", () => { + registry.delete(name); + }); + } + + const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId }; + res.json(response); + }); + + // DELETE /pages/:name - close a page + app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => { + const name = decodeURIComponent(req.params.name); + const entry = registry.get(name); + + if (entry) { + await entry.page.close(); + registry.delete(name); + res.json({ success: true }); + return; + } + + res.status(404).json({ error: "page not found" }); + }); + + // Start the server + const server = app.listen(port, () => { + console.log(`HTTP API server running on port ${port}`); + }); + + // Track active connections for clean shutdown + const connections = new Set(); + server.on("connection", (socket: Socket) => { + connections.add(socket); + socket.on("close", () => connections.delete(socket)); + }); + + // Track if cleanup has been called to avoid double cleanup + let cleaningUp = false; + + // Cleanup function + const cleanup = async () => { + if (cleaningUp) return; + cleaningUp = true; + + console.log("\nShutting down..."); + + // Close all active HTTP connections + for (const socket of connections) { + socket.destroy(); + } + connections.clear(); + + // Close all pages + for (const entry of registry.values()) { + try { + await entry.page.close(); + } catch { + // Page might already be closed + } + } + registry.clear(); + + // Close context (this also closes the browser) + try { + await context.close(); + } catch { + // Context might already be closed + } + + server.close(); + console.log("Server stopped."); + }; + + // Synchronous cleanup for forced exits + const syncCleanup = () => { + try { + context.close(); + } catch { + // Best effort + } + }; + + // Signal handlers (consolidated to reduce duplication) + const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + + const signalHandler = async () => { + await cleanup(); + process.exit(0); + }; + + const errorHandler = async (err: unknown) => { + console.error("Unhandled error:", err); + await cleanup(); + process.exit(1); + }; + + // Register handlers + signals.forEach((sig) => process.on(sig, signalHandler)); + process.on("uncaughtException", errorHandler); + process.on("unhandledRejection", errorHandler); + process.on("exit", syncCleanup); + + // Helper to remove all handlers + const removeHandlers = () => { + signals.forEach((sig) => process.off(sig, signalHandler)); + process.off("uncaughtException", errorHandler); + process.off("unhandledRejection", errorHandler); + process.off("exit", syncCleanup); + }; + + return { + wsEndpoint, + port, + async stop() { + removeHandlers(); + await cleanup(); + }, + }; +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts new file mode 100644 index 000000000..cb19b6e71 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts @@ -0,0 +1,732 @@ +/** + * CDP Relay Server for Chrome Extension mode + * + * This server acts as a bridge between Playwright clients and a Chrome extension. + * Instead of launching a browser, it waits for the extension to connect and + * forwards CDP commands/events between them. + */ + +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { createNodeWebSocket } from "@hono/node-ws"; +import type { WSContext } from "hono/ws"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface RelayOptions { + port?: number; + host?: string; +} + +export interface RelayServer { + wsEndpoint: string; + port: number; + stop(): Promise; +} + +interface TargetInfo { + targetId: string; + type: string; + title: string; + url: string; + attached: boolean; +} + +interface ConnectedTarget { + sessionId: string; + targetId: string; + targetInfo: TargetInfo; +} + +interface PlaywrightClient { + id: string; + ws: WSContext; + knownTargets: Set; // targetIds this client has received attachedToTarget for +} + +// Message types for extension communication +interface ExtensionCommandMessage { + id: number; + method: "forwardCDPCommand"; + params: { + method: string; + params?: Record; + sessionId?: string; + }; +} + +interface ExtensionResponseMessage { + id: number; + result?: unknown; + error?: string; +} + +interface ExtensionEventMessage { + method: "forwardCDPEvent"; + params: { + method: string; + params?: Record; + sessionId?: string; + }; +} + +type ExtensionMessage = + | ExtensionResponseMessage + | ExtensionEventMessage + | { method: "log"; params: { level: string; args: string[] } }; + +// CDP message types +interface CDPCommand { + id: number; + method: string; + params?: Record; + sessionId?: string; +} + +interface CDPResponse { + id: number; + sessionId?: string; + result?: unknown; + error?: { message: string }; +} + +interface CDPEvent { + method: string; + sessionId?: string; + params?: Record; +} + +// ============================================================================ +// Relay Server Implementation +// ============================================================================ + +export async function serveRelay(options: RelayOptions = {}): Promise { + // Accomplish uses port 9224 to avoid conflicts with Claude Code's dev-browser (9222) + const port = options.port ?? 9224; + const host = options.host ?? "127.0.0.1"; + + // State + const connectedTargets = new Map(); + const namedPages = new Map(); // name -> sessionId + const playwrightClients = new Map(); + let extensionWs: WSContext | null = null; + + // Pending requests to extension + const extensionPendingRequests = new Map< + number, + { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + } + >(); + let extensionMessageId = 0; + + // ============================================================================ + // Helper Functions + // ============================================================================ + + function log(...args: unknown[]) { + console.log("[relay]", ...args); + } + + function sendToPlaywright(message: CDPResponse | CDPEvent, clientId?: string) { + const messageStr = JSON.stringify(message); + + if (clientId) { + const client = playwrightClients.get(clientId); + if (client) { + client.ws.send(messageStr); + } + } else { + // Broadcast to all clients + for (const client of playwrightClients.values()) { + client.ws.send(messageStr); + } + } + } + + /** + * Send Target.attachedToTarget event with deduplication. + * Tracks which targets each client has seen to prevent "Duplicate target" errors. + */ + function sendAttachedToTarget( + target: ConnectedTarget, + clientId?: string, + waitingForDebugger = false + ) { + const event: CDPEvent = { + method: "Target.attachedToTarget", + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger, + }, + }; + + if (clientId) { + const client = playwrightClients.get(clientId); + if (client && !client.knownTargets.has(target.targetId)) { + client.knownTargets.add(target.targetId); + client.ws.send(JSON.stringify(event)); + } + } else { + // Broadcast to all clients that don't know about this target yet + for (const client of playwrightClients.values()) { + if (!client.knownTargets.has(target.targetId)) { + client.knownTargets.add(target.targetId); + client.ws.send(JSON.stringify(event)); + } + } + } + } + + async function sendToExtension({ + method, + params, + timeout = 30000, + }: { + method: string; + params?: Record; + timeout?: number; + }): Promise { + if (!extensionWs) { + throw new Error("Extension not connected"); + } + + const id = ++extensionMessageId; + const message = { id, method, params }; + + extensionWs.send(JSON.stringify(message)); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + extensionPendingRequests.delete(id); + reject(new Error(`Extension request timeout after ${timeout}ms: ${method}`)); + }, timeout); + + extensionPendingRequests.set(id, { + resolve: (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (error) => { + clearTimeout(timeoutId); + reject(error); + }, + }); + }); + } + + async function routeCdpCommand({ + method, + params, + sessionId, + }: { + method: string; + params?: Record; + sessionId?: string; + }): Promise { + // Handle some CDP commands locally + switch (method) { + case "Browser.getVersion": + return { + protocolVersion: "1.3", + product: "Chrome/Extension-Bridge", + revision: "1.0.0", + userAgent: "dev-browser-relay/1.0.0", + jsVersion: "V8", + }; + + case "Browser.setDownloadBehavior": + return {}; + + case "Target.setAutoAttach": + if (sessionId) { + break; // Forward to extension for child frames + } + return {}; + + case "Target.setDiscoverTargets": + return {}; + + case "Target.attachToBrowserTarget": + // Browser-level session - return a fake session since we only proxy tabs + return { sessionId: "browser" }; + + case "Target.detachFromTarget": + // If detaching from our fake "browser" session, just return success + if (sessionId === "browser" || params?.sessionId === "browser") { + return {}; + } + // Otherwise forward to extension + break; + + case "Target.attachToTarget": { + const targetId = params?.targetId as string; + if (!targetId) { + throw new Error("targetId is required for Target.attachToTarget"); + } + + for (const target of connectedTargets.values()) { + if (target.targetId === targetId) { + return { sessionId: target.sessionId }; + } + } + + throw new Error(`Target ${targetId} not found in connected targets`); + } + + case "Target.getTargetInfo": { + const targetId = params?.targetId as string; + + if (targetId) { + for (const target of connectedTargets.values()) { + if (target.targetId === targetId) { + return { targetInfo: target.targetInfo }; + } + } + } + + if (sessionId) { + const target = connectedTargets.get(sessionId); + if (target) { + return { targetInfo: target.targetInfo }; + } + } + + // Return first target if no specific one requested + const firstTarget = Array.from(connectedTargets.values())[0]; + return { targetInfo: firstTarget?.targetInfo }; + } + + case "Target.getTargets": + return { + targetInfos: Array.from(connectedTargets.values()).map((t) => ({ + ...t.targetInfo, + attached: true, + })), + }; + + case "Target.createTarget": + case "Target.closeTarget": + // Forward to extension + return await sendToExtension({ + method: "forwardCDPCommand", + params: { method, params }, + }); + } + + // Forward all other commands to extension + return await sendToExtension({ + method: "forwardCDPCommand", + params: { sessionId, method, params }, + }); + } + + // ============================================================================ + // HTTP/WebSocket Server + // ============================================================================ + + const app = new Hono(); + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + + // Health check / server info + app.get("/", (c) => { + return c.json({ + wsEndpoint: `ws://${host}:${port}/cdp`, + extensionConnected: extensionWs !== null, + mode: "extension", + }); + }); + + // List named pages + app.get("/pages", (c) => { + return c.json({ + pages: Array.from(namedPages.keys()), + }); + }); + + // Get or create a named page + app.post("/pages", async (c) => { + const body = await c.req.json(); + const name = body.name as string; + + if (!name) { + return c.json({ error: "name is required" }, 400); + } + + // Check if page already exists by name + const existingSessionId = namedPages.get(name); + if (existingSessionId) { + const target = connectedTargets.get(existingSessionId); + if (target) { + // Activate the tab so it becomes the active tab + await sendToExtension({ + method: "forwardCDPCommand", + params: { + method: "Target.activateTarget", + params: { targetId: target.targetId }, + }, + }); + return c.json({ + wsEndpoint: `ws://${host}:${port}/cdp`, + name, + targetId: target.targetId, + url: target.targetInfo.url, + }); + } + // Session no longer valid, remove it + namedPages.delete(name); + } + + // Create a new tab + if (!extensionWs) { + return c.json({ error: "Extension not connected" }, 503); + } + + try { + const result = (await sendToExtension({ + method: "forwardCDPCommand", + params: { method: "Target.createTarget", params: { url: "about:blank" } }, + })) as { targetId: string }; + + // Wait for Target.attachedToTarget event to register the new target + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Find and name the new target + for (const [sessionId, target] of connectedTargets) { + if (target.targetId === result.targetId) { + namedPages.set(name, sessionId); + // Activate the tab so it becomes the active tab + await sendToExtension({ + method: "forwardCDPCommand", + params: { + method: "Target.activateTarget", + params: { targetId: target.targetId }, + }, + }); + return c.json({ + wsEndpoint: `ws://${host}:${port}/cdp`, + name, + targetId: target.targetId, + url: target.targetInfo.url, + }); + } + } + + throw new Error("Target created but not found in registry"); + } catch (err) { + log("Error creating tab:", err); + return c.json({ error: (err as Error).message }, 500); + } + }); + + // Delete a named page (removes the name, doesn't close the tab) + app.delete("/pages/:name", (c) => { + const name = c.req.param("name"); + const deleted = namedPages.delete(name); + return c.json({ success: deleted }); + }); + + // ============================================================================ + // Playwright Client WebSocket + // ============================================================================ + + app.get( + "/cdp/:clientId?", + upgradeWebSocket((c) => { + const clientId = + c.req.param("clientId") || `client-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + return { + onOpen(_event, ws) { + if (playwrightClients.has(clientId)) { + log(`Rejecting duplicate client ID: ${clientId}`); + ws.close(1000, "Client ID already connected"); + return; + } + + playwrightClients.set(clientId, { id: clientId, ws, knownTargets: new Set() }); + log(`Playwright client connected: ${clientId}`); + }, + + async onMessage(event, _ws) { + let message: CDPCommand; + + try { + message = JSON.parse(event.data.toString()); + } catch { + return; + } + + const { id, sessionId, method, params } = message; + + if (!extensionWs) { + sendToPlaywright( + { + id, + sessionId, + error: { message: "Extension not connected" }, + }, + clientId + ); + return; + } + + try { + const result = await routeCdpCommand({ method, params, sessionId }); + + // After Target.setAutoAttach, send attachedToTarget for existing targets + // Uses deduplication to prevent "Duplicate target" errors + if (method === "Target.setAutoAttach" && !sessionId) { + for (const target of connectedTargets.values()) { + sendAttachedToTarget(target, clientId); + } + } + + // After Target.setDiscoverTargets, send targetCreated events + if ( + method === "Target.setDiscoverTargets" && + (params as { discover?: boolean })?.discover + ) { + for (const target of connectedTargets.values()) { + sendToPlaywright( + { + method: "Target.targetCreated", + params: { + targetInfo: { ...target.targetInfo, attached: true }, + }, + }, + clientId + ); + } + } + + // After Target.attachToTarget, send attachedToTarget event (with deduplication) + if ( + method === "Target.attachToTarget" && + (result as { sessionId?: string })?.sessionId + ) { + const targetId = params?.targetId as string; + const target = Array.from(connectedTargets.values()).find( + (t) => t.targetId === targetId + ); + if (target) { + sendAttachedToTarget(target, clientId); + } + } + + sendToPlaywright({ id, sessionId, result }, clientId); + } catch (e) { + log("Error handling CDP command:", method, e); + sendToPlaywright( + { + id, + sessionId, + error: { message: (e as Error).message }, + }, + clientId + ); + } + }, + + onClose() { + playwrightClients.delete(clientId); + log(`Playwright client disconnected: ${clientId}`); + }, + + onError(event) { + log(`Playwright WebSocket error [${clientId}]:`, event); + }, + }; + }) + ); + + // ============================================================================ + // Extension WebSocket + // ============================================================================ + + app.get( + "/extension", + upgradeWebSocket(() => { + return { + onOpen(_event, ws) { + if (extensionWs) { + log("Closing existing extension connection"); + extensionWs.close(4001, "Extension Replaced"); + + // Clear state + connectedTargets.clear(); + namedPages.clear(); + for (const pending of extensionPendingRequests.values()) { + pending.reject(new Error("Extension connection replaced")); + } + extensionPendingRequests.clear(); + } + + extensionWs = ws; + log("Extension connected"); + }, + + async onMessage(event, ws) { + let message: ExtensionMessage; + + try { + message = JSON.parse(event.data.toString()); + } catch { + ws.close(1000, "Invalid JSON"); + return; + } + + // Handle response to our request + if ("id" in message && typeof message.id === "number") { + const pending = extensionPendingRequests.get(message.id); + if (!pending) { + log("Unexpected response with id:", message.id); + return; + } + + extensionPendingRequests.delete(message.id); + + if ((message as ExtensionResponseMessage).error) { + pending.reject(new Error((message as ExtensionResponseMessage).error)); + } else { + pending.resolve((message as ExtensionResponseMessage).result); + } + return; + } + + // Handle log messages + if ("method" in message && message.method === "log") { + const { level, args } = message.params; + console.log(`[extension:${level}]`, ...args); + return; + } + + // Handle CDP events from extension + if ("method" in message && message.method === "forwardCDPEvent") { + const eventMsg = message as ExtensionEventMessage; + const { method, params, sessionId } = eventMsg.params; + + // Handle target lifecycle events + if (method === "Target.attachedToTarget") { + const targetParams = params as { + sessionId: string; + targetInfo: TargetInfo; + }; + + const target: ConnectedTarget = { + sessionId: targetParams.sessionId, + targetId: targetParams.targetInfo.targetId, + targetInfo: targetParams.targetInfo, + }; + connectedTargets.set(targetParams.sessionId, target); + + log(`Target attached: ${targetParams.targetInfo.url} (${targetParams.sessionId})`); + + // Use deduplication helper - only sends to clients that don't know about this target + sendAttachedToTarget(target); + } else if (method === "Target.detachedFromTarget") { + const detachParams = params as { sessionId: string }; + connectedTargets.delete(detachParams.sessionId); + + // Also remove any name mapping + for (const [name, sid] of namedPages) { + if (sid === detachParams.sessionId) { + namedPages.delete(name); + break; + } + } + + log(`Target detached: ${detachParams.sessionId}`); + + sendToPlaywright({ + method: "Target.detachedFromTarget", + params: detachParams, + }); + } else if (method === "Target.targetInfoChanged") { + const infoParams = params as { targetInfo: TargetInfo }; + for (const target of connectedTargets.values()) { + if (target.targetId === infoParams.targetInfo.targetId) { + target.targetInfo = infoParams.targetInfo; + break; + } + } + + sendToPlaywright({ + method: "Target.targetInfoChanged", + params: infoParams, + }); + } else { + // Forward other CDP events to Playwright + sendToPlaywright({ + sessionId, + method, + params, + }); + } + } + }, + + onClose(_event, ws) { + if (extensionWs && extensionWs !== ws) { + log("Old extension connection closed"); + return; + } + + log("Extension disconnected"); + + for (const pending of extensionPendingRequests.values()) { + pending.reject(new Error("Extension connection closed")); + } + extensionPendingRequests.clear(); + + extensionWs = null; + connectedTargets.clear(); + namedPages.clear(); + + // Close all Playwright clients + for (const client of playwrightClients.values()) { + client.ws.close(1000, "Extension disconnected"); + } + playwrightClients.clear(); + }, + + onError(event) { + log("Extension WebSocket error:", event); + }, + }; + }) + ); + + // ============================================================================ + // Start Server + // ============================================================================ + + const server = serve({ fetch: app.fetch, port, hostname: host }); + injectWebSocket(server); + + const wsEndpoint = `ws://${host}:${port}/cdp`; + + log("CDP relay server started"); + log(` HTTP: http://${host}:${port}`); + log(` CDP endpoint: ${wsEndpoint}`); + log(` Extension endpoint: ws://${host}:${port}/extension`); + log(""); + log("Waiting for extension to connect..."); + + return { + wsEndpoint, + port, + async stop() { + for (const client of playwrightClients.values()) { + client.ws.close(1000, "Server stopped"); + } + playwrightClients.clear(); + extensionWs?.close(1000, "Server stopped"); + server.close(); + }, + }; +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts new file mode 100644 index 000000000..8439fd72c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts @@ -0,0 +1,223 @@ +import { chromium } from "playwright"; +import type { Browser, BrowserContext, Page } from "playwright"; +import { beforeAll, afterAll, beforeEach, afterEach, describe, test, expect } from "vitest"; +import { getSnapshotScript, clearSnapshotScriptCache } from "../browser-script"; + +let browser: Browser; +let context: BrowserContext; +let page: Page; + +beforeAll(async () => { + browser = await chromium.launch(); +}); + +afterAll(async () => { + await browser.close(); +}); + +beforeEach(async () => { + context = await browser.newContext(); + page = await context.newPage(); + clearSnapshotScriptCache(); // Start fresh for each test +}); + +afterEach(async () => { + await context.close(); +}); + +async function setContent(html: string): Promise { + await page.setContent(html, { waitUntil: "domcontentloaded" }); +} + +async function getSnapshot(): Promise { + const script = getSnapshotScript(); + return await page.evaluate((s: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + if (!w.__devBrowser_getAISnapshot) { + // eslint-disable-next-line no-eval + eval(s); + } + return w.__devBrowser_getAISnapshot(); + }, script); +} + +async function selectRef(ref: string): Promise { + return await page.evaluate((refId: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + const element = w.__devBrowser_selectSnapshotRef(refId); + return { + tagName: element.tagName, + textContent: element.textContent?.trim(), + }; + }, ref); +} + +describe("ARIA Snapshot", () => { + test("generates snapshot for simple page", async () => { + await setContent(` + + +

Hello World

+ + + + `); + + const snapshot = await getSnapshot(); + + expect(snapshot).toContain("heading"); + expect(snapshot).toContain("Hello World"); + expect(snapshot).toContain("button"); + expect(snapshot).toContain("Click me"); + }); + + test("assigns refs to interactive elements", async () => { + await setContent(` + + + + + + + `); + + const snapshot = await getSnapshot(); + + // Should have refs + expect(snapshot).toMatch(/\[ref=e\d+\]/); + }); + + test("refs persist on window.__devBrowserRefs", async () => { + await setContent(` + + + + + + `); + + await getSnapshot(); + + // Check that refs are stored + const hasRefs = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = globalThis as any; + return typeof w.__devBrowserRefs === "object" && Object.keys(w.__devBrowserRefs).length > 0; + }); + + expect(hasRefs).toBe(true); + }); + + test("selectSnapshotRef returns element for valid ref", async () => { + await setContent(` + + + + + + `); + + const snapshot = await getSnapshot(); + + // Extract a ref from the snapshot + const refMatch = snapshot.match(/\[ref=(e\d+)\]/); + expect(refMatch).toBeTruthy(); + expect(refMatch![1]).toBeDefined(); + const ref = refMatch![1] as string; + + // Select the element by ref + const result = (await selectRef(ref)) as { tagName: string; textContent: string }; + expect(result.tagName).toBe("BUTTON"); + expect(result.textContent).toBe("My Button"); + }); + + test("includes links with URLs", async () => { + await setContent(` + + +
Example Link + + + `); + + const snapshot = await getSnapshot(); + + expect(snapshot).toContain("link"); + expect(snapshot).toContain("Example Link"); + // URL should be included as a prop + expect(snapshot).toContain("/url:"); + }); + + test("includes form elements", async () => { + await setContent(` + + + + + + + + `); + + const snapshot = await getSnapshot(); + + expect(snapshot).toContain("textbox"); + expect(snapshot).toContain("checkbox"); + expect(snapshot).toContain("combobox"); + }); + + test("renders nested structure correctly", async () => { + await setContent(` + + + + + + `); + + const snapshot = await getSnapshot(); + + expect(snapshot).toContain("navigation"); + expect(snapshot).toContain("list"); + expect(snapshot).toContain("listitem"); + expect(snapshot).toContain("link"); + }); + + test("handles disabled elements", async () => { + await setContent(` + + + + + + `); + + const snapshot = await getSnapshot(); + + expect(snapshot).toContain("[disabled]"); + }); + + test("handles checked checkboxes", async () => { + await setContent(` + + + + + + `); + + const snapshot = await getSnapshot(); + + expect(snapshot).toContain("[checked]"); + }); +}); diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts new file mode 100644 index 000000000..133e637d9 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts @@ -0,0 +1,877 @@ +/** + * Browser-injectable snapshot script. + * + * This module provides the snapshot functionality as a string that can be + * injected into the browser via page.addScriptTag() or page.evaluate(). + * + * The approach is to read the compiled JavaScript at runtime and bundle it + * into a single script that exposes window.__devBrowser_getAISnapshot() and + * window.__devBrowser_selectSnapshotRef(). + */ + +import * as fs from "fs"; +import * as path from "path"; + +// Cache the bundled script +let cachedScript: string | null = null; + +/** + * Get the snapshot script that can be injected into the browser. + * Returns a self-contained JavaScript string that: + * 1. Defines all necessary functions (domUtils, roleUtils, yaml, ariaSnapshot) + * 2. Exposes window.__devBrowser_getAISnapshot() + * 3. Exposes window.__devBrowser_selectSnapshotRef() + */ +export function getSnapshotScript(): string { + if (cachedScript) return cachedScript; + + // Read the compiled JavaScript files + const snapshotDir = path.dirname(new URL(import.meta.url).pathname); + + // For now, we'll inline the functions directly + // In production, we could use a bundler like esbuild to create a single file + cachedScript = ` +(function() { + // Skip if already injected + if (window.__devBrowser_getAISnapshot) return; + + ${getDomUtilsCode()} + ${getYamlCode()} + ${getRoleUtilsCode()} + ${getAriaSnapshotCode()} + + // Expose main functions + window.__devBrowser_getAISnapshot = getAISnapshot; + window.__devBrowser_selectSnapshotRef = selectSnapshotRef; +})(); +`; + + return cachedScript; +} + +function getDomUtilsCode(): string { + return ` +// === domUtils === +let cacheStyle; +let cachesCounter = 0; + +function beginDOMCaches() { + ++cachesCounter; + cacheStyle = cacheStyle || new Map(); +} + +function endDOMCaches() { + if (!--cachesCounter) { + cacheStyle = undefined; + } +} + +function getElementComputedStyle(element, pseudo) { + const cache = cacheStyle; + const cacheKey = pseudo ? undefined : element; + if (cache && cacheKey && cache.has(cacheKey)) return cache.get(cacheKey); + const style = element.ownerDocument && element.ownerDocument.defaultView + ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) + : undefined; + if (cache && cacheKey) cache.set(cacheKey, style); + return style; +} + +function parentElementOrShadowHost(element) { + if (element.parentElement) return element.parentElement; + if (!element.parentNode) return; + if (element.parentNode.nodeType === 11 && element.parentNode.host) + return element.parentNode.host; +} + +function enclosingShadowRootOrDocument(element) { + let node = element; + while (node.parentNode) node = node.parentNode; + if (node.nodeType === 11 || node.nodeType === 9) + return node; +} + +function closestCrossShadow(element, css, scope) { + while (element) { + const closest = element.closest(css); + if (scope && closest !== scope && closest?.contains(scope)) return; + if (closest) return closest; + element = enclosingShadowHost(element); + } +} + +function enclosingShadowHost(element) { + while (element.parentElement) element = element.parentElement; + return parentElementOrShadowHost(element); +} + +function isElementStyleVisibilityVisible(element, style) { + style = style || getElementComputedStyle(element); + if (!style) return true; + if (style.visibility !== "visible") return false; + const detailsOrSummary = element.closest("details,summary"); + if (detailsOrSummary !== element && detailsOrSummary?.nodeName === "DETAILS" && !detailsOrSummary.open) + return false; + return true; +} + +function computeBox(element) { + const style = getElementComputedStyle(element); + if (!style) return { visible: true, inline: false }; + const cursor = style.cursor; + if (style.display === "contents") { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && isElementVisible(child)) + return { visible: true, inline: false, cursor }; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return { visible: true, inline: true, cursor }; + } + return { visible: false, inline: false, cursor }; + } + if (!isElementStyleVisibilityVisible(element, style)) + return { cursor, visible: false, inline: false }; + const rect = element.getBoundingClientRect(); + return { rect, cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === "inline" }; +} + +function isElementVisible(element) { + return computeBox(element).visible; +} + +function isVisibleTextNode(node) { + const range = node.ownerDocument.createRange(); + range.selectNode(node); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +function elementSafeTagName(element) { + const tagName = element.tagName; + if (typeof tagName === "string") return tagName.toUpperCase(); + if (element instanceof HTMLFormElement) return "FORM"; + return element.tagName.toUpperCase(); +} + +function normalizeWhiteSpace(text) { + return text.split("\\u00A0").map(chunk => + chunk.replace(/\\r\\n/g, "\\n").replace(/[\\u200b\\u00ad]/g, "").replace(/\\s\\s*/g, " ") + ).join("\\u00A0").trim(); +} +`; +} + +function getYamlCode(): string { + return ` +// === yaml === +function yamlEscapeKeyIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) return str; + return "'" + str.replace(/'/g, "''") + "'"; +} + +function yamlEscapeValueIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) return str; + return '"' + str.replace(/[\\\\"\x00-\\x1f\\x7f-\\x9f]/g, c => { + switch (c) { + case "\\\\": return "\\\\\\\\"; + case '"': return '\\\\"'; + case "\\b": return "\\\\b"; + case "\\f": return "\\\\f"; + case "\\n": return "\\\\n"; + case "\\r": return "\\\\r"; + case "\\t": return "\\\\t"; + default: + const code = c.charCodeAt(0); + return "\\\\x" + code.toString(16).padStart(2, "0"); + } + }) + '"'; +} + +function yamlStringNeedsQuotes(str) { + if (str.length === 0) return true; + if (/^\\s|\\s$/.test(str)) return true; + if (/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f-\\x9f]/.test(str)) return true; + if (/^-/.test(str)) return true; + if (/[\\n:](\\s|$)/.test(str)) return true; + if (/\\s#/.test(str)) return true; + if (/[\\n\\r]/.test(str)) return true; + if (/^[&*\\],?!>|@"'#%]/.test(str)) return true; + if (/[{}\`]/.test(str)) return true; + if (/^\\[/.test(str)) return true; + if (!isNaN(Number(str)) || ["y","n","yes","no","true","false","on","off","null"].includes(str.toLowerCase())) return true; + return false; +} +`; +} + +function getRoleUtilsCode(): string { + return ` +// === roleUtils === +const validRoles = ["alert","alertdialog","application","article","banner","blockquote","button","caption","cell","checkbox","code","columnheader","combobox","complementary","contentinfo","definition","deletion","dialog","directory","document","emphasis","feed","figure","form","generic","grid","gridcell","group","heading","img","insertion","link","list","listbox","listitem","log","main","mark","marquee","math","meter","menu","menubar","menuitem","menuitemcheckbox","menuitemradio","navigation","none","note","option","paragraph","presentation","progressbar","radio","radiogroup","region","row","rowgroup","rowheader","scrollbar","search","searchbox","separator","slider","spinbutton","status","strong","subscript","superscript","switch","tab","table","tablist","tabpanel","term","textbox","time","timer","toolbar","tooltip","tree","treegrid","treeitem"]; + +let cacheAccessibleName; +let cacheIsHidden; +let cachePointerEvents; +let ariaCachesCounter = 0; + +function beginAriaCaches() { + beginDOMCaches(); + ++ariaCachesCounter; + cacheAccessibleName = cacheAccessibleName || new Map(); + cacheIsHidden = cacheIsHidden || new Map(); + cachePointerEvents = cachePointerEvents || new Map(); +} + +function endAriaCaches() { + if (!--ariaCachesCounter) { + cacheAccessibleName = undefined; + cacheIsHidden = undefined; + cachePointerEvents = undefined; + } + endDOMCaches(); +} + +function hasExplicitAccessibleName(e) { + return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby"); +} + +const kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]"; + +const kGlobalAriaAttributes = [ + ["aria-atomic", undefined],["aria-busy", undefined],["aria-controls", undefined],["aria-current", undefined], + ["aria-describedby", undefined],["aria-details", undefined],["aria-dropeffect", undefined],["aria-flowto", undefined], + ["aria-grabbed", undefined],["aria-hidden", undefined],["aria-keyshortcuts", undefined], + ["aria-label", ["caption","code","deletion","emphasis","generic","insertion","paragraph","presentation","strong","subscript","superscript"]], + ["aria-labelledby", ["caption","code","deletion","emphasis","generic","insertion","paragraph","presentation","strong","subscript","superscript"]], + ["aria-live", undefined],["aria-owns", undefined],["aria-relevant", undefined],["aria-roledescription", ["generic"]] +]; + +function hasGlobalAriaAttribute(element, forRole) { + return kGlobalAriaAttributes.some(([attr, prohibited]) => !prohibited?.includes(forRole || "") && element.hasAttribute(attr)); +} + +function hasTabIndex(element) { + return !Number.isNaN(Number(String(element.getAttribute("tabindex")))); +} + +function isFocusable(element) { + return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element)); +} + +function isNativelyFocusable(element) { + const tagName = elementSafeTagName(element); + if (["BUTTON","DETAILS","SELECT","TEXTAREA"].includes(tagName)) return true; + if (tagName === "A" || tagName === "AREA") return element.hasAttribute("href"); + if (tagName === "INPUT") return !element.hidden; + return false; +} + +function isNativelyDisabled(element) { + const isNativeFormControl = ["BUTTON","INPUT","SELECT","TEXTAREA","OPTION","OPTGROUP"].includes(elementSafeTagName(element)); + return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledFieldSet(element)); +} + +function belongsToDisabledFieldSet(element) { + const fieldSetElement = element?.closest("FIELDSET[DISABLED]"); + if (!fieldSetElement) return false; + const legendElement = fieldSetElement.querySelector(":scope > LEGEND"); + return !legendElement || !legendElement.contains(element); +} + +const inputTypeToRole = {button:"button",checkbox:"checkbox",image:"button",number:"spinbutton",radio:"radio",range:"slider",reset:"button",submit:"button"}; + +function getIdRefs(element, ref) { + if (!ref) return []; + const root = enclosingShadowRootOrDocument(element); + if (!root) return []; + try { + const ids = ref.split(" ").filter(id => !!id); + const result = []; + for (const id of ids) { + const firstElement = root.querySelector("#" + CSS.escape(id)); + if (firstElement && !result.includes(firstElement)) result.push(firstElement); + } + return result; + } catch { return []; } +} + +const kImplicitRoleByTagName = { + A: e => e.hasAttribute("href") ? "link" : null, + AREA: e => e.hasAttribute("href") ? "link" : null, + ARTICLE: () => "article", ASIDE: () => "complementary", BLOCKQUOTE: () => "blockquote", BUTTON: () => "button", + CAPTION: () => "caption", CODE: () => "code", DATALIST: () => "listbox", DD: () => "definition", + DEL: () => "deletion", DETAILS: () => "group", DFN: () => "term", DIALOG: () => "dialog", DT: () => "term", + EM: () => "emphasis", FIELDSET: () => "group", FIGURE: () => "figure", + FOOTER: e => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo", + FORM: e => hasExplicitAccessibleName(e) ? "form" : null, + H1: () => "heading", H2: () => "heading", H3: () => "heading", H4: () => "heading", H5: () => "heading", H6: () => "heading", + HEADER: e => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner", + HR: () => "separator", HTML: () => "document", + IMG: e => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img", + INPUT: e => { + const type = e.type.toLowerCase(); + if (type === "search") return e.hasAttribute("list") ? "combobox" : "searchbox"; + if (["email","tel","text","url",""].includes(type)) { + const list = getIdRefs(e, e.getAttribute("list"))[0]; + return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox"; + } + if (type === "hidden") return null; + if (type === "file") return "button"; + return inputTypeToRole[type] || "textbox"; + }, + INS: () => "insertion", LI: () => "listitem", MAIN: () => "main", MARK: () => "mark", MATH: () => "math", + MENU: () => "list", METER: () => "meter", NAV: () => "navigation", OL: () => "list", OPTGROUP: () => "group", + OPTION: () => "option", OUTPUT: () => "status", P: () => "paragraph", PROGRESS: () => "progressbar", + SEARCH: () => "search", SECTION: e => hasExplicitAccessibleName(e) ? "region" : null, + SELECT: e => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox", + STRONG: () => "strong", SUB: () => "subscript", SUP: () => "superscript", SVG: () => "img", + TABLE: () => "table", TBODY: () => "rowgroup", + TD: e => { const table = closestCrossShadow(e, "table"); const role = table ? getExplicitAriaRole(table) : ""; return role === "grid" || role === "treegrid" ? "gridcell" : "cell"; }, + TEXTAREA: () => "textbox", TFOOT: () => "rowgroup", + TH: e => { const scope = e.getAttribute("scope"); if (scope === "col" || scope === "colgroup") return "columnheader"; if (scope === "row" || scope === "rowgroup") return "rowheader"; return "columnheader"; }, + THEAD: () => "rowgroup", TIME: () => "time", TR: () => "row", UL: () => "list" +}; + +function getExplicitAriaRole(element) { + const roles = (element.getAttribute("role") || "").split(" ").map(role => role.trim()); + return roles.find(role => validRoles.includes(role)) || null; +} + +function getImplicitAriaRole(element) { + const fn = kImplicitRoleByTagName[elementSafeTagName(element)]; + return fn ? fn(element) : null; +} + +function hasPresentationConflictResolution(element, role) { + return hasGlobalAriaAttribute(element, role) || isFocusable(element); +} + +function getAriaRole(element) { + const explicitRole = getExplicitAriaRole(element); + if (!explicitRole) return getImplicitAriaRole(element); + if (explicitRole === "none" || explicitRole === "presentation") { + const implicitRole = getImplicitAriaRole(element); + if (hasPresentationConflictResolution(element, implicitRole)) return implicitRole; + } + return explicitRole; +} + +function getAriaBoolean(attr) { + return attr === null ? undefined : attr.toLowerCase() === "true"; +} + +function isElementIgnoredForAria(element) { + return ["STYLE","SCRIPT","NOSCRIPT","TEMPLATE"].includes(elementSafeTagName(element)); +} + +function isElementHiddenForAria(element) { + if (isElementIgnoredForAria(element)) return true; + const style = getElementComputedStyle(element); + const isSlot = element.nodeName === "SLOT"; + if (style?.display === "contents" && !isSlot) { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && !isElementHiddenForAria(child)) return false; + if (child.nodeType === 3 && isVisibleTextNode(child)) return false; + } + return true; + } + const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select"); + if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) return true; + return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element); +} + +function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) { + let hidden = cacheIsHidden?.get(element); + if (hidden === undefined) { + hidden = false; + if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot) hidden = true; + if (!hidden) { + const style = getElementComputedStyle(element); + hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true; + } + if (!hidden) { + const parent = parentElementOrShadowHost(element); + if (parent) hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent); + } + cacheIsHidden?.set(element, hidden); + } + return hidden; +} + +function getAriaLabelledByElements(element) { + const ref = element.getAttribute("aria-labelledby"); + if (ref === null) return null; + const refs = getIdRefs(element, ref); + return refs.length ? refs : null; +} + +function getElementAccessibleName(element, includeHidden) { + let accessibleName = cacheAccessibleName?.get(element); + if (accessibleName === undefined) { + accessibleName = ""; + const elementProhibitsNaming = ["caption","code","definition","deletion","emphasis","generic","insertion","mark","paragraph","presentation","strong","subscript","suggestion","superscript","term","time"].includes(getAriaRole(element) || ""); + if (!elementProhibitsNaming) { + accessibleName = normalizeWhiteSpace(getTextAlternativeInternal(element, { includeHidden, visitedElements: new Set(), embeddedInTargetElement: "self" })); + } + cacheAccessibleName?.set(element, accessibleName); + } + return accessibleName; +} + +function getTextAlternativeInternal(element, options) { + if (options.visitedElements.has(element)) return ""; + const childOptions = { ...options, embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement }; + + if (!options.includeHidden) { + const isEmbeddedInHiddenReferenceTraversal = !!options.embeddedInLabelledBy?.hidden || !!options.embeddedInLabel?.hidden; + if (isElementIgnoredForAria(element) || (!isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element))) { + options.visitedElements.add(element); + return ""; + } + } + + const labelledBy = getAriaLabelledByElements(element); + if (!options.embeddedInLabelledBy) { + const accessibleName = (labelledBy || []).map(ref => getTextAlternativeInternal(ref, { ...options, embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, embeddedInTargetElement: undefined, embeddedInLabel: undefined })).join(" "); + if (accessibleName) return accessibleName; + } + + const role = getAriaRole(element) || ""; + const tagName = elementSafeTagName(element); + + const ariaLabel = element.getAttribute("aria-label") || ""; + if (ariaLabel.trim()) { options.visitedElements.add(element); return ariaLabel; } + + if (!["presentation","none"].includes(role)) { + if (tagName === "INPUT" && ["button","submit","reset"].includes(element.type)) { + options.visitedElements.add(element); + const value = element.value || ""; + if (value.trim()) return value; + if (element.type === "submit") return "Submit"; + if (element.type === "reset") return "Reset"; + return element.getAttribute("title") || ""; + } + if (tagName === "INPUT" && element.type === "image") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (alt.trim()) return alt; + const title = element.getAttribute("title") || ""; + if (title.trim()) return title; + return "Submit"; + } + if (tagName === "IMG") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (alt.trim()) return alt; + return element.getAttribute("title") || ""; + } + if (!labelledBy && ["BUTTON","INPUT","TEXTAREA","SELECT"].includes(tagName)) { + const labels = element.labels; + if (labels?.length) { + options.visitedElements.add(element); + return [...labels].map(label => getTextAlternativeInternal(label, { ...options, embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, embeddedInLabelledBy: undefined, embeddedInTargetElement: undefined })).filter(name => !!name).join(" "); + } + } + } + + const allowsNameFromContent = ["button","cell","checkbox","columnheader","gridcell","heading","link","menuitem","menuitemcheckbox","menuitemradio","option","radio","row","rowheader","switch","tab","tooltip","treeitem"].includes(role); + if (allowsNameFromContent || !!options.embeddedInLabelledBy || !!options.embeddedInLabel) { + options.visitedElements.add(element); + const accessibleName = innerAccumulatedElementText(element, childOptions); + const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? accessibleName.trim() : accessibleName; + if (maybeTrimmedAccessibleName) return accessibleName; + } + + if (!["presentation","none"].includes(role) || tagName === "IFRAME") { + options.visitedElements.add(element); + const title = element.getAttribute("title") || ""; + if (title.trim()) return title; + } + + options.visitedElements.add(element); + return ""; +} + +function innerAccumulatedElementText(element, options) { + const tokens = []; + const visit = (node, skipSlotted) => { + if (skipSlotted && node.assignedSlot) return; + if (node.nodeType === 1) { + const display = getElementComputedStyle(node)?.display || "inline"; + let token = getTextAlternativeInternal(node, options); + if (display !== "inline" || node.nodeName === "BR") token = " " + token + " "; + tokens.push(token); + } else if (node.nodeType === 3) { + tokens.push(node.textContent || ""); + } + }; + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) visit(child, false); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) visit(child, true); + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(child, true); + } + } + return tokens.join(""); +} + +const kAriaCheckedRoles = ["checkbox","menuitemcheckbox","option","radio","switch","menuitemradio","treeitem"]; +function getAriaChecked(element) { + const tagName = elementSafeTagName(element); + if (tagName === "INPUT" && element.indeterminate) return "mixed"; + if (tagName === "INPUT" && ["checkbox","radio"].includes(element.type)) return element.checked; + if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) { + const checked = element.getAttribute("aria-checked"); + if (checked === "true") return true; + if (checked === "mixed") return "mixed"; + return false; + } + return false; +} + +const kAriaDisabledRoles = ["application","button","composite","gridcell","group","input","link","menuitem","scrollbar","separator","tab","checkbox","columnheader","combobox","grid","listbox","menu","menubar","menuitemcheckbox","menuitemradio","option","radio","radiogroup","row","rowheader","searchbox","select","slider","spinbutton","switch","tablist","textbox","toolbar","tree","treegrid","treeitem"]; +function getAriaDisabled(element) { + return isNativelyDisabled(element) || hasExplicitAriaDisabled(element); +} +function hasExplicitAriaDisabled(element, isAncestor) { + if (!element) return false; + if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) { + const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase(); + if (attribute === "true") return true; + if (attribute === "false") return false; + return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true); + } + return false; +} + +const kAriaExpandedRoles = ["application","button","checkbox","combobox","gridcell","link","listbox","menuitem","row","rowheader","tab","treeitem","columnheader","menuitemcheckbox","menuitemradio","switch"]; +function getAriaExpanded(element) { + if (elementSafeTagName(element) === "DETAILS") return element.open; + if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) { + const expanded = element.getAttribute("aria-expanded"); + if (expanded === null) return undefined; + if (expanded === "true") return true; + return false; + } + return undefined; +} + +const kAriaLevelRoles = ["heading","listitem","row","treeitem"]; +function getAriaLevel(element) { + const native = {H1:1,H2:2,H3:3,H4:4,H5:5,H6:6}[elementSafeTagName(element)]; + if (native) return native; + if (kAriaLevelRoles.includes(getAriaRole(element) || "")) { + const attr = element.getAttribute("aria-level"); + const value = attr === null ? Number.NaN : Number(attr); + if (Number.isInteger(value) && value >= 1) return value; + } + return 0; +} + +const kAriaPressedRoles = ["button"]; +function getAriaPressed(element) { + if (kAriaPressedRoles.includes(getAriaRole(element) || "")) { + const pressed = element.getAttribute("aria-pressed"); + if (pressed === "true") return true; + if (pressed === "mixed") return "mixed"; + } + return false; +} + +const kAriaSelectedRoles = ["gridcell","option","row","tab","rowheader","columnheader","treeitem"]; +function getAriaSelected(element) { + if (elementSafeTagName(element) === "OPTION") return element.selected; + if (kAriaSelectedRoles.includes(getAriaRole(element) || "")) return getAriaBoolean(element.getAttribute("aria-selected")) === true; + return false; +} + +function receivesPointerEvents(element) { + const cache = cachePointerEvents; + let e = element; + let result; + const parents = []; + for (; e; e = parentElementOrShadowHost(e)) { + const cached = cache?.get(e); + if (cached !== undefined) { result = cached; break; } + parents.push(e); + const style = getElementComputedStyle(e); + if (!style) { result = true; break; } + const value = style.pointerEvents; + if (value) { result = value !== "none"; break; } + } + if (result === undefined) result = true; + for (const parent of parents) cache?.set(parent, result); + return result; +} + +function getCSSContent(element, pseudo) { + const style = getElementComputedStyle(element, pseudo); + if (!style) return undefined; + const contentValue = style.content; + if (!contentValue || contentValue === "none" || contentValue === "normal") return undefined; + if (style.display === "none" || style.visibility === "hidden") return undefined; + const match = contentValue.match(/^"(.*)"$/); + if (match) { + const content = match[1].replace(/\\\\"/g, '"'); + if (pseudo) { + const display = style.display || "inline"; + if (display !== "inline") return " " + content + " "; + } + return content; + } + return undefined; +} +`; +} + +function getAriaSnapshotCode(): string { + return ` +// === ariaSnapshot === +let lastRef = 0; + +function generateAriaTree(rootElement) { + const options = { visibility: "ariaOrVisible", refs: "interactable", refPrefix: "", includeGenericRole: true, renderActive: true, renderCursorPointer: true }; + const visited = new Set(); + const snapshot = { + root: { role: "fragment", name: "", children: [], element: rootElement, props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, + elements: new Map(), + refs: new Map(), + iframeRefs: [] + }; + + const visit = (ariaNode, node, parentElementVisible) => { + if (visited.has(node)) return; + visited.add(node); + if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { + if (!parentElementVisible) return; + const text = node.nodeValue; + if (ariaNode.role !== "textbox" && text) ariaNode.children.push(node.nodeValue || ""); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const element = node; + const isElementVisibleForAria = !isElementHiddenForAria(element); + let visible = isElementVisibleForAria; + if (options.visibility === "ariaOrVisible") visible = isElementVisibleForAria || isElementVisible(element); + if (options.visibility === "ariaAndVisible") visible = isElementVisibleForAria && isElementVisible(element); + if (options.visibility === "aria" && !visible) return; + const ariaChildren = []; + if (element.hasAttribute("aria-owns")) { + const ids = element.getAttribute("aria-owns").split(/\\s+/); + for (const id of ids) { + const ownedElement = rootElement.ownerDocument.getElementById(id); + if (ownedElement) ariaChildren.push(ownedElement); + } + } + const childAriaNode = visible ? toAriaNode(element, options) : null; + if (childAriaNode) { + if (childAriaNode.ref) { + snapshot.elements.set(childAriaNode.ref, element); + snapshot.refs.set(element, childAriaNode.ref); + if (childAriaNode.role === "iframe") snapshot.iframeRefs.push(childAriaNode.ref); + } + ariaNode.children.push(childAriaNode); + } + processElement(childAriaNode || ariaNode, element, ariaChildren, visible); + }; + + function processElement(ariaNode, element, ariaChildren, parentElementVisible) { + const display = getElementComputedStyle(element)?.display || "inline"; + const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : ""; + if (treatAsBlock) ariaNode.children.push(treatAsBlock); + ariaNode.children.push(getCSSContent(element, "::before") || ""); + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) visit(ariaNode, child, parentElementVisible); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (!child.assignedSlot) visit(ariaNode, child, parentElementVisible); + } + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) visit(ariaNode, child, parentElementVisible); + } + } + for (const child of ariaChildren) visit(ariaNode, child, parentElementVisible); + ariaNode.children.push(getCSSContent(element, "::after") || ""); + if (treatAsBlock) ariaNode.children.push(treatAsBlock); + if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) ariaNode.children = []; + if (ariaNode.role === "link" && element.hasAttribute("href")) ariaNode.props["url"] = element.getAttribute("href"); + if (ariaNode.role === "textbox" && element.hasAttribute("placeholder") && element.getAttribute("placeholder") !== ariaNode.name) ariaNode.props["placeholder"] = element.getAttribute("placeholder"); + } + + beginAriaCaches(); + try { visit(snapshot.root, rootElement, true); } + finally { endAriaCaches(); } + normalizeStringChildren(snapshot.root); + normalizeGenericRoles(snapshot.root); + return snapshot; +} + +function computeAriaRef(ariaNode, options) { + if (options.refs === "none") return; + if (options.refs === "interactable" && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) return; + let ariaRef = ariaNode.element._ariaRef; + if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) { + ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix || "") + "e" + (++lastRef) }; + ariaNode.element._ariaRef = ariaRef; + } + ariaNode.ref = ariaRef.ref; +} + +function toAriaNode(element, options) { + const active = element.ownerDocument.activeElement === element; + if (element.nodeName === "IFRAME") { + const ariaNode = { role: "iframe", name: "", children: [], props: {}, element, box: computeBox(element), receivesPointerEvents: true, active }; + computeAriaRef(ariaNode, options); + return ariaNode; + } + const defaultRole = options.includeGenericRole ? "generic" : null; + const role = getAriaRole(element) || defaultRole; + if (!role || role === "presentation" || role === "none") return null; + const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || ""); + const receivesPointerEventsValue = receivesPointerEvents(element); + const box = computeBox(element); + if (role === "generic" && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) return null; + const result = { role, name, children: [], props: {}, element, box, receivesPointerEvents: receivesPointerEventsValue, active }; + computeAriaRef(result, options); + if (kAriaCheckedRoles.includes(role)) result.checked = getAriaChecked(element); + if (kAriaDisabledRoles.includes(role)) result.disabled = getAriaDisabled(element); + if (kAriaExpandedRoles.includes(role)) result.expanded = getAriaExpanded(element); + if (kAriaLevelRoles.includes(role)) result.level = getAriaLevel(element); + if (kAriaPressedRoles.includes(role)) result.pressed = getAriaPressed(element); + if (kAriaSelectedRoles.includes(role)) result.selected = getAriaSelected(element); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.type !== "checkbox" && element.type !== "radio" && element.type !== "file") result.children = [element.value]; + } + return result; +} + +function normalizeGenericRoles(node) { + const normalizeChildren = (node) => { + const result = []; + for (const child of node.children || []) { + if (typeof child === "string") { result.push(child); continue; } + const normalized = normalizeChildren(child); + result.push(...normalized); + } + const removeSelf = node.role === "generic" && !node.name && result.length <= 1 && result.every(c => typeof c !== "string" && !!c.ref); + if (removeSelf) return result; + node.children = result; + return [node]; + }; + normalizeChildren(node); +} + +function normalizeStringChildren(rootA11yNode) { + const flushChildren = (buffer, normalizedChildren) => { + if (!buffer.length) return; + const text = normalizeWhiteSpace(buffer.join("")); + if (text) normalizedChildren.push(text); + buffer.length = 0; + }; + const visit = (ariaNode) => { + const normalizedChildren = []; + const buffer = []; + for (const child of ariaNode.children || []) { + if (typeof child === "string") { buffer.push(child); } + else { flushChildren(buffer, normalizedChildren); visit(child); normalizedChildren.push(child); } + } + flushChildren(buffer, normalizedChildren); + ariaNode.children = normalizedChildren.length ? normalizedChildren : []; + if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) ariaNode.children = []; + }; + visit(rootA11yNode); +} + +function hasPointerCursor(ariaNode) { return ariaNode.box.cursor === "pointer"; } + +function renderAriaTree(ariaSnapshot) { + const options = { visibility: "ariaOrVisible", refs: "interactable", refPrefix: "", includeGenericRole: true, renderActive: true, renderCursorPointer: true }; + const lines = []; + let nodesToRender = ariaSnapshot.root.role === "fragment" ? ariaSnapshot.root.children : [ariaSnapshot.root]; + + const visitText = (text, indent) => { + const escaped = yamlEscapeValueIfNeeded(text); + if (escaped) lines.push(indent + "- text: " + escaped); + }; + + const createKey = (ariaNode, renderCursorPointer) => { + let key = ariaNode.role; + if (ariaNode.name && ariaNode.name.length <= 900) { + const name = ariaNode.name; + if (name) { + const stringifiedName = name.startsWith("/") && name.endsWith("/") ? name : JSON.stringify(name); + key += " " + stringifiedName; + } + } + if (ariaNode.checked === "mixed") key += " [checked=mixed]"; + if (ariaNode.checked === true) key += " [checked]"; + if (ariaNode.disabled) key += " [disabled]"; + if (ariaNode.expanded) key += " [expanded]"; + if (ariaNode.active && options.renderActive) key += " [active]"; + if (ariaNode.level) key += " [level=" + ariaNode.level + "]"; + if (ariaNode.pressed === "mixed") key += " [pressed=mixed]"; + if (ariaNode.pressed === true) key += " [pressed]"; + if (ariaNode.selected === true) key += " [selected]"; + if (ariaNode.ref) { + key += " [ref=" + ariaNode.ref + "]"; + if (renderCursorPointer && hasPointerCursor(ariaNode)) key += " [cursor=pointer]"; + } + return key; + }; + + const getSingleInlinedTextChild = (ariaNode) => { + return ariaNode?.children.length === 1 && typeof ariaNode.children[0] === "string" && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : undefined; + }; + + const visit = (ariaNode, indent, renderCursorPointer) => { + const escapedKey = indent + "- " + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); + const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); + if (!ariaNode.children.length && !Object.keys(ariaNode.props).length) { + lines.push(escapedKey); + } else if (singleInlinedTextChild !== undefined) { + lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(singleInlinedTextChild)); + } else { + lines.push(escapedKey + ":"); + for (const [name, value] of Object.entries(ariaNode.props)) lines.push(indent + " - /" + name + ": " + yamlEscapeValueIfNeeded(value)); + const childIndent = indent + " "; + const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode); + for (const child of ariaNode.children) { + if (typeof child === "string") visitText(child, childIndent); + else visit(child, childIndent, renderCursorPointer && !inCursorPointer); + } + } + }; + + for (const nodeToRender of nodesToRender) { + if (typeof nodeToRender === "string") visitText(nodeToRender, ""); + else visit(nodeToRender, "", !!options.renderCursorPointer); + } + return lines.join("\\n"); +} + +function getAISnapshot() { + const snapshot = generateAriaTree(document.body); + const refsObject = {}; + for (const [ref, element] of snapshot.elements) refsObject[ref] = element; + window.__devBrowserRefs = refsObject; + return renderAriaTree(snapshot); +} + +function selectSnapshotRef(ref) { + const refs = window.__devBrowserRefs; + if (!refs) throw new Error("No snapshot refs found. Call getAISnapshot first."); + const element = refs[ref]; + if (!element) throw new Error('Ref "' + ref + '" not found. Available refs: ' + Object.keys(refs).join(", ")); + return element; +} +`; +} + +/** + * Clear the cached script (useful for development/testing) + */ +export function clearSnapshotScriptCache(): void { + cachedScript = null; +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts new file mode 100644 index 000000000..d713f6b5d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts @@ -0,0 +1,14 @@ +/** + * ARIA Snapshot module for dev-browser. + * + * Provides Playwright-compatible ARIA snapshots with cross-connection ref persistence. + * Refs are stored on window.__devBrowserRefs and survive across Playwright reconnections. + * + * Usage: + * import { getSnapshotScript } from './snapshot'; + * const script = getSnapshotScript(); + * await page.evaluate(script); + * // Now window.__devBrowser_getAISnapshot() and window.__devBrowser_selectSnapshotRef(ref) are available + */ + +export { getSnapshotScript, clearSnapshotScriptCache } from "./browser-script"; diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts new file mode 100644 index 000000000..2392221ba --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts @@ -0,0 +1,13 @@ +/** + * Injectable snapshot script for browser context. + * + * This module provides the getSnapshotScript function that returns a + * self-contained JavaScript string for injection into browser contexts. + * + * The script is injected via page.evaluate() and exposes: + * - window.__devBrowser_getAISnapshot(): Returns ARIA snapshot YAML + * - window.__devBrowser_selectSnapshotRef(ref): Returns element for given ref + * - window.__devBrowserRefs: Map of ref -> Element (persists across connections) + */ + +export { getSnapshotScript, clearSnapshotScriptCache } from "./browser-script"; diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts new file mode 100644 index 000000000..fdf650800 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts @@ -0,0 +1,36 @@ +// API request/response types - shared between client and server + +export interface ServeOptions { + port?: number; + headless?: boolean; + cdpPort?: number; + /** Directory to store persistent browser profiles (cookies, localStorage, etc.) */ + profileDir?: string; + /** Try to use system Chrome first before falling back to Playwright Chromium */ + useSystemChrome?: boolean; +} + +export interface ViewportSize { + width: number; + height: number; +} + +export interface GetPageRequest { + name: string; + /** Optional viewport size for new pages */ + viewport?: ViewportSize; +} + +export interface GetPageResponse { + wsEndpoint: string; + name: string; + targetId: string; // CDP target ID for reliable page matching +} + +export interface ListPagesResponse { + pages: string[]; +} + +export interface ServerInfoResponse { + wsEndpoint: string; +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json b/openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json new file mode 100644 index 000000000..3bce709ed --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": [ + "ESNext" + ], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": [ + "src/**/*", + "scripts/**/*" + ] +} diff --git a/openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts b/openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts new file mode 100644 index 000000000..e2f469e37 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + testTimeout: 60000, // Playwright tests can be slow + hookTimeout: 60000, + teardownTimeout: 60000, + }, +}); diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/package-lock.json b/openwork-memos-integration/apps/desktop/skills/file-permission/package-lock.json new file mode 100644 index 000000000..de75c468d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/file-permission/package-lock.json @@ -0,0 +1,1650 @@ +{ + "name": "file-permission", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "file-permission", + "version": "0.0.1", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", + "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.7", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/package.json b/openwork-memos-integration/apps/desktop/skills/file-permission/package.json new file mode 100644 index 000000000..e6c0bc754 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/file-permission/package.json @@ -0,0 +1,17 @@ +{ + "name": "file-permission", + "version": "0.0.1", + "type": "module", + "imports": { + "@/*": "./src/*" + }, + "scripts": { + "start": "npx tsx src/index.ts", + "dev": "npx tsx --watch src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "tsx": "^4.21.0", + "typescript": "^5.0.0" + } +} diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts b/openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts new file mode 100644 index 000000000..e7bfe914f --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** + * File Permission MCP Server + * + * Exposes a `request_file_permission` tool that the agent calls before + * performing file operations. The tool communicates with the Electron + * main process via HTTP to show a permission modal and wait for user response. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolResult, +} from '@modelcontextprotocol/sdk/types.js'; + +const PERMISSION_API_PORT = process.env.PERMISSION_API_PORT || '9226'; +const PERMISSION_API_URL = `http://localhost:${PERMISSION_API_PORT}/permission`; + +interface FilePermissionInput { + operation: 'create' | 'delete' | 'rename' | 'move' | 'modify' | 'overwrite'; + filePath?: string; + filePaths?: string[]; + targetPath?: string; + contentPreview?: string; +} + +const server = new Server( + { name: 'file-permission', version: '1.0.0' }, + { capabilities: { tools: {} } } +); + +// List available tools +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'request_file_permission', + description: + 'Request user permission before performing file operations (create, delete, rename, move, modify, overwrite). Always call this tool BEFORE executing any file modification. Returns "allowed" or "denied".', + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + enum: ['create', 'delete', 'rename', 'move', 'modify', 'overwrite'], + description: 'The type of file operation to perform', + }, + filePath: { + type: 'string', + description: 'Absolute path to the file being operated on', + }, + filePaths: { + type: 'array', + items: { type: 'string' }, + description: 'Array of absolute paths for batch operations (e.g., deleting multiple files)', + }, + targetPath: { + type: 'string', + description: 'Target path for rename/move operations', + }, + contentPreview: { + type: 'string', + description: 'Preview of file content for create/modify operations (first ~500 chars)', + }, + }, + required: ['operation'], + }, + }, + ], +})); + +// Handle tool calls +server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { + if (request.params.name !== 'request_file_permission') { + return { + content: [{ type: 'text', text: `Error: Unknown tool: ${request.params.name}` }], + isError: true, + }; + } + + const args = request.params.arguments as FilePermissionInput; + const { operation, filePath, filePaths, targetPath, contentPreview } = args; + + // Validate required fields + if (!operation || (!filePath && (!filePaths || filePaths.length === 0))) { + return { + content: [{ type: 'text', text: 'Error: operation and either filePath or filePaths are required' }], + isError: true, + }; + } + + try { + // Call Electron main process HTTP endpoint + const response = await fetch(PERMISSION_API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + operation, + filePath, + filePaths, + targetPath, + contentPreview: contentPreview?.substring(0, 500), // Truncate preview + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + content: [{ type: 'text', text: `Error: Permission API returned ${response.status}: ${errorText}` }], + isError: true, + }; + } + + const result = (await response.json()) as { allowed: boolean }; + return { + content: [{ type: 'text', text: result.allowed ? 'allowed' : 'denied' }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text', text: `Error: Failed to request permission: ${errorMessage}` }], + isError: true, + }; + } +}); + +// Start the MCP server +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('File Permission MCP Server started'); +} + +main().catch((error) => { + console.error('Failed to start server:', error); + process.exit(1); +}); diff --git a/openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json b/openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json new file mode 100644 index 000000000..341042abe --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md b/openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md new file mode 100644 index 000000000..37cca78bb --- /dev/null +++ b/openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md @@ -0,0 +1,50 @@ +--- +name: safe-file-deletion +description: Enforces explicit user permission before any file deletion. Activates when you're about to use rm, unlink, fs.rm, or any operation that removes files from disk. MUST be followed for all delete operations. +--- + +# Safe File Deletion + +## Rule + +Before deleting ANY file, you MUST: + +1. Call `request_file_permission` with `operation: "delete"` +2. For multiple files, use `filePaths` array (not multiple calls) +3. Wait for response +4. Only proceed if "allowed" +5. If "denied", acknowledge and do NOT delete + +## Applies To + +- `rm` commands (single or multiple files) +- `rm -rf` (directories) +- `unlink`, `fs.rm`, `fs.rmdir` +- Any script or tool that deletes files + +## Examples + +Single file: +```json +{ + "operation": "delete", + "filePath": "/path/to/file.txt" +} +``` + +Multiple files (batched into one prompt): +```json +{ + "operation": "delete", + "filePaths": ["/path/to/file1.txt", "/path/to/file2.txt"] +} +``` + +## No Workarounds + +Never bypass deletion warnings by: +- Emptying files instead of deleting +- Moving to hidden/temp locations +- Using obscure commands + +The user will see a prominent warning. Wait for explicit approval. diff --git a/openwork-memos-integration/apps/desktop/src/main/config.ts b/openwork-memos-integration/apps/desktop/src/main/config.ts new file mode 100644 index 000000000..3674638a7 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/config.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +const PRODUCTION_API_URL = 'https://lite.accomplish.ai'; + +const desktopConfigSchema = z.object({ + apiUrl: z + .string() + .url() + .default(PRODUCTION_API_URL), +}); + +type DesktopConfig = z.infer; + +let cachedConfig: DesktopConfig | null = null; + +export function getDesktopConfig(): DesktopConfig { + if (cachedConfig) return cachedConfig; + + const parsed = desktopConfigSchema.safeParse({ + apiUrl: process.env.ACCOMPLISH_API_URL, + }); + + if (!parsed.success) { + const message = parsed.error.issues.map((issue: z.ZodIssue) => issue.message).join('; '); + throw new Error(`Invalid desktop configuration: ${message}`); + } + + cachedConfig = parsed.data; + return cachedConfig; +} diff --git a/openwork-memos-integration/apps/desktop/src/main/index.ts b/openwork-memos-integration/apps/desktop/src/main/index.ts new file mode 100644 index 000000000..d1c7010df --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/index.ts @@ -0,0 +1,223 @@ +import { config } from 'dotenv'; +import { app, BrowserWindow, shell, ipcMain, nativeImage } from 'electron'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { registerIPCHandlers } from './ipc/handlers'; +import { flushPendingTasks } from './store/taskHistory'; +import { disposeTaskManager } from './opencode/task-manager'; +import { checkAndCleanupFreshInstall } from './store/freshInstallCleanup'; + +// Local UI - no longer uses remote URL + +// Early E2E flag detection - check command-line args before anything else +// This must run synchronously at module load time +if (process.argv.includes('--e2e-skip-auth')) { + (global as Record).E2E_SKIP_AUTH = true; +} +if (process.argv.includes('--e2e-mock-tasks') || process.env.E2E_MOCK_TASK_EVENTS === '1') { + (global as Record).E2E_MOCK_TASK_EVENTS = true; +} + +// Clean mode - wipe all stored data for a fresh start +// Use CLEAN_START env var since CLI args don't pass through vite to Electron +if (process.env.CLEAN_START === '1') { + const userDataPath = app.getPath('userData'); + console.log('[Clean Mode] Clearing userData directory:', userDataPath); + try { + if (fs.existsSync(userDataPath)) { + fs.rmSync(userDataPath, { recursive: true, force: true }); + console.log('[Clean Mode] Successfully cleared userData'); + } + } catch (err) { + console.error('[Clean Mode] Failed to clear userData:', err); + } + // Note: Secure storage (API keys, auth tokens) is stored in electron-store + // which lives in userData, so it gets cleared with the directory above +} + +// Set app name before anything else (affects deep link dialogs) +app.name = 'Openwork'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env file from app root +const envPath = app.isPackaged + ? path.join(process.resourcesPath, '.env') + : path.join(__dirname, '../../.env'); +config({ path: envPath }); + +// The built directory structure +// +// ├─┬ dist-electron +// │ ├─┬ main +// │ │ └── index.js > Electron-Main +// │ └─┬ preload +// │ └── index.js > Preload-Scripts +// ├─┬ dist +// │ └── index.html > Electron-Renderer + +process.env.APP_ROOT = path.join(__dirname, '../..'); + +export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron'); +export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist'); +export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; + +let mainWindow: BrowserWindow | null = null; + +// Get the preload script path +function getPreloadPath(): string { + return path.join(__dirname, '../preload/index.cjs'); +} + +function createWindow() { + console.log('[Main] Creating main application window'); + + // Get app icon + const iconPath = app.isPackaged + ? path.join(process.resourcesPath, 'icon.png') + : path.join(process.env.APP_ROOT!, 'resources', 'icon.png'); + const icon = nativeImage.createFromPath(iconPath); + + const preloadPath = getPreloadPath(); + console.log('[Main] Using preload script:', preloadPath); + + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 900, + minHeight: 600, + title: 'Openwork', + icon: icon.isEmpty() ? undefined : icon, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + trafficLightPosition: { x: 16, y: 16 }, + webPreferences: { + preload: preloadPath, + nodeIntegration: false, + contextIsolation: true, + }, + }); + + // Open external links in browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('https:') || url.startsWith('http:')) { + shell.openExternal(url); + } + return { action: 'deny' }; + }); + + // Maximize window by default + mainWindow.maximize(); + + // Open DevTools in dev mode (non-packaged), but not during E2E tests + const isE2EMode = (global as Record).E2E_SKIP_AUTH === true; + if (!app.isPackaged && !isE2EMode) { + mainWindow.webContents.openDevTools({ mode: 'right' }); + } + + // Load the local UI + if (VITE_DEV_SERVER_URL) { + console.log('[Main] Loading from Vite dev server:', VITE_DEV_SERVER_URL); + mainWindow.loadURL(VITE_DEV_SERVER_URL); + } else { + const indexPath = path.join(RENDERER_DIST, 'index.html'); + console.log('[Main] Loading from file:', indexPath); + mainWindow.loadFile(indexPath); + } +} + +// Single instance lock +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + console.log('[Main] Second instance attempted; quitting'); + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + console.log('[Main] Focused existing instance after second-instance event'); + } + }); + + app.whenReady().then(async () => { + console.log('[Main] Electron app ready, version:', app.getVersion()); + + // Check for fresh install and cleanup old data BEFORE initializing stores + // This ensures users get a clean slate after reinstalling from DMG + try { + const didCleanup = await checkAndCleanupFreshInstall(); + if (didCleanup) { + console.log('[Main] Cleaned up data from previous installation'); + } + } catch (err) { + console.error('[Main] Fresh install cleanup failed:', err); + } + + // Set dock icon on macOS + if (process.platform === 'darwin' && app.dock) { + const iconPath = app.isPackaged + ? path.join(process.resourcesPath, 'icon.png') + : path.join(process.env.APP_ROOT!, 'resources', 'icon.png'); + const icon = nativeImage.createFromPath(iconPath); + if (!icon.isEmpty()) { + app.dock.setIcon(icon); + } + } + + // Register IPC handlers before creating window + registerIPCHandlers(); + console.log('[Main] IPC handlers registered'); + + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + console.log('[Main] Application reactivated; recreated window'); + } + }); + }); +} + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + console.log('[Main] All windows closed; quitting app'); + app.quit(); + } +}); + +// Flush pending task history writes and dispose TaskManager before quitting +app.on('before-quit', () => { + console.log('[Main] App before-quit event fired'); + flushPendingTasks(); + // Dispose all active tasks and cleanup PTY processes + disposeTaskManager(); +}); + +// Handle custom protocol (accomplish://) +app.setAsDefaultProtocolClient('accomplish'); + +app.on('open-url', (event, url) => { + event.preventDefault(); + console.log('[Main] Received protocol URL:', url); + // Handle protocol URL + if (url.startsWith('accomplish://callback')) { + mainWindow?.webContents?.send('auth:callback', url); + } +}); + +// IPC Handlers +ipcMain.handle('app:version', () => { + return app.getVersion(); +}); + +ipcMain.handle('app:platform', () => { + return process.platform; +}); + +ipcMain.handle('app:is-e2e-mode', () => { + return (global as Record).E2E_MOCK_TASK_EVENTS === true || + process.env.E2E_MOCK_TASK_EVENTS === '1'; +}); diff --git a/openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts b/openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts new file mode 100644 index 000000000..0c694b41c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts @@ -0,0 +1,1721 @@ +import { ipcMain, BrowserWindow, shell, app } from 'electron'; +import type { IpcMainInvokeEvent } from 'electron'; +import { URL } from 'url'; +import { + isOpenCodeCliInstalled, + getOpenCodeCliVersion, +} from '../opencode/adapter'; +import { + getTaskManager, + disposeTaskManager, + type TaskCallbacks, +} from '../opencode/task-manager'; +import { + getTasks, + getTask, + saveTask, + updateTaskStatus, + updateTaskSessionId, + updateTaskSummary, + addTaskMessage, + deleteTask, + clearHistory, +} from '../store/taskHistory'; +import { generateTaskSummary } from '../services/summarizer'; +import { getMemoryContextForPrompt, rememberTask } from '../services/memory'; +import { + storeApiKey, + getApiKey, + deleteApiKey, + getAllApiKeys, + hasAnyApiKey, + listStoredCredentials, +} from '../store/secureStorage'; +import { + getDebugMode, + setDebugMode, + getAppSettings, + getOnboardingComplete, + setOnboardingComplete, + getSelectedModel, + setSelectedModel, + getOllamaConfig, + setOllamaConfig, + getLiteLLMConfig, + setLiteLLMConfig, +} from '../store/appSettings'; +import { getDesktopConfig } from '../config'; +import { + startPermissionApiServer, + startQuestionApiServer, + initPermissionApi, + resolvePermission, + resolveQuestion, + isFilePermissionRequest, + isQuestionRequest, +} from '../permission-api'; +import type { + TaskConfig, + PermissionResponse, + OpenCodeMessage, + TaskMessage, + TaskResult, + TaskStatus, + SelectedModel, + OllamaConfig, + LiteLLMConfig, +} from '@accomplish/shared'; +import { DEFAULT_PROVIDERS } from '@accomplish/shared'; +import { + normalizeIpcError, + permissionResponseSchema, + resumeSessionSchema, + taskConfigSchema, + validate, +} from './validation'; +import { BedrockClient, ListFoundationModelsCommand } from '@aws-sdk/client-bedrock'; +import { fromIni } from '@aws-sdk/credential-providers'; +import { + isMockTaskEventsEnabled, + createMockTask, + executeMockTaskFlow, + detectScenarioFromPrompt, +} from '../test-utils/mock-task-flow'; + +const MAX_TEXT_LENGTH = 8000; +const ALLOWED_API_KEY_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google', 'xai', 'deepseek', 'zai', 'custom', 'bedrock', 'litellm']); +const API_KEY_VALIDATION_TIMEOUT_MS = 15000; + +interface OllamaModel { + id: string; + displayName: string; + size: number; +} + +/** + * Fetch with timeout using AbortController + */ +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + return response; + } finally { + clearTimeout(timeoutId); + } +} + +// Message batching configuration +const MESSAGE_BATCH_DELAY_MS = 50; + +// Per-task message batching state +interface MessageBatcher { + pendingMessages: TaskMessage[]; + timeout: NodeJS.Timeout | null; + taskId: string; + flush: () => void; +} + +const messageBatchers = new Map(); + +function createMessageBatcher( + taskId: string, + forwardToRenderer: (channel: string, data: unknown) => void, + addTaskMessage: (taskId: string, message: TaskMessage) => void +): MessageBatcher { + const batcher: MessageBatcher = { + pendingMessages: [], + timeout: null, + taskId, + flush: () => { + if (batcher.pendingMessages.length === 0) return; + + // Send all pending messages in one IPC call + forwardToRenderer('task:update:batch', { + taskId, + messages: batcher.pendingMessages, + }); + + // Also persist each message to history + for (const msg of batcher.pendingMessages) { + addTaskMessage(taskId, msg); + } + + batcher.pendingMessages = []; + if (batcher.timeout) { + clearTimeout(batcher.timeout); + batcher.timeout = null; + } + }, + }; + + messageBatchers.set(taskId, batcher); + return batcher; +} + +function queueMessage( + taskId: string, + message: TaskMessage, + forwardToRenderer: (channel: string, data: unknown) => void, + addTaskMessage: (taskId: string, message: TaskMessage) => void +): void { + let batcher = messageBatchers.get(taskId); + if (!batcher) { + batcher = createMessageBatcher(taskId, forwardToRenderer, addTaskMessage); + } + + batcher.pendingMessages.push(message); + + // Set up or reset the batch timer + if (batcher.timeout) { + clearTimeout(batcher.timeout); + } + + batcher.timeout = setTimeout(() => { + batcher.flush(); + }, MESSAGE_BATCH_DELAY_MS); +} + +function flushAndCleanupBatcher(taskId: string): void { + const batcher = messageBatchers.get(taskId); + if (batcher) { + batcher.flush(); + messageBatchers.delete(taskId); + } +} + +function assertTrustedWindow(window: BrowserWindow | null): BrowserWindow { + if (!window || window.isDestroyed()) { + throw new Error('Untrusted window'); + } + + const focused = BrowserWindow.getFocusedWindow(); + if (BrowserWindow.getAllWindows().length > 1 && focused && focused.id !== window.id) { + throw new Error('IPC request must originate from the focused window'); + } + + return window; +} + +function sanitizeString(input: unknown, field: string, maxLength = MAX_TEXT_LENGTH): string { + if (typeof input !== 'string') { + throw new Error(`${field} must be a string`); + } + const trimmed = input.trim(); + if (!trimmed) { + throw new Error(`${field} is required`); + } + if (trimmed.length > maxLength) { + throw new Error(`${field} exceeds maximum length`); + } + return trimmed; +} + +function applyMemoryContext(config: TaskConfig, memoryContext: string | null): TaskConfig { + if (!memoryContext) return config; + + const combined = [config.systemPromptAppend, memoryContext] + .filter(Boolean) + .join('\n\n'); + + const trimmed = combined.length > MAX_TEXT_LENGTH + ? combined.slice(0, Math.max(0, MAX_TEXT_LENGTH - 3)) + '...' + : combined; + + if (trimmed.length !== combined.length) { + console.warn('[Memory] systemPromptAppend truncated to MAX_TEXT_LENGTH'); + } + + return { ...config, systemPromptAppend: trimmed }; +} + +function validateTaskConfig(config: TaskConfig): TaskConfig { + const prompt = sanitizeString(config.prompt, 'prompt'); + const validated: TaskConfig = { prompt }; + + if (config.taskId) { + validated.taskId = sanitizeString(config.taskId, 'taskId', 128); + } + if (config.sessionId) { + validated.sessionId = sanitizeString(config.sessionId, 'sessionId', 128); + } + if (config.workingDirectory) { + validated.workingDirectory = sanitizeString(config.workingDirectory, 'workingDirectory', 1024); + } + if (Array.isArray(config.allowedTools)) { + validated.allowedTools = config.allowedTools + .filter((tool): tool is string => typeof tool === 'string') + .map((tool) => sanitizeString(tool, 'allowedTools', 64)) + .slice(0, 20); + } + if (config.systemPromptAppend) { + validated.systemPromptAppend = sanitizeString( + config.systemPromptAppend, + 'systemPromptAppend', + MAX_TEXT_LENGTH + ); + } + if (config.outputSchema && typeof config.outputSchema === 'object') { + validated.outputSchema = config.outputSchema; + } + + return validated; +} + +/** + * Check if E2E auth bypass is enabled via global flag, command-line argument, or environment variable + * Global flag is set by Playwright's app.evaluate() and is most reliable across platforms + */ +function isE2ESkipAuthEnabled(): boolean { + return ( + (global as Record).E2E_SKIP_AUTH === true || + process.argv.includes('--e2e-skip-auth') || + process.env.E2E_SKIP_AUTH === '1' + ); +} + +function handle( + channel: string, + handler: (event: IpcMainInvokeEvent, ...args: Args) => ReturnType +): void { + ipcMain.handle(channel, async (event, ...args) => { + try { + return await handler(event, ...(args as Args)); + } catch (error) { + console.error(`IPC handler ${channel} failed`, error); + throw normalizeIpcError(error); + } + }); +} + +/** + * Register all IPC handlers + */ +export function registerIPCHandlers(): void { + const taskManager = getTaskManager(); + + // Start the permission API server for file-permission MCP + // Initialize when we have a window (deferred until first task:start) + let permissionApiInitialized = false; + + // Task: Start a new task + handle('task:start', async (event: IpcMainInvokeEvent, config: TaskConfig) => { + const window = assertTrustedWindow(BrowserWindow.fromWebContents(event.sender)); + const sender = event.sender; + const validatedConfig = validateTaskConfig(config); + + // Initialize permission API server (once, when we have a window) + if (!permissionApiInitialized) { + initPermissionApi(window, () => taskManager.getActiveTaskId()); + startPermissionApiServer(); + startQuestionApiServer(); + permissionApiInitialized = true; + } + + const taskId = createTaskId(); + const memoryContext = await getMemoryContextForPrompt(validatedConfig.prompt, taskId); + const configWithMemory = applyMemoryContext(validatedConfig, memoryContext); + + // E2E Mock Mode: Return mock task and emit simulated events + if (isMockTaskEventsEnabled()) { + const mockTask = createMockTask(taskId, validatedConfig.prompt); + const scenario = detectScenarioFromPrompt(validatedConfig.prompt); + + // Save task to history so Execution page can load it + saveTask(mockTask); + + // Execute mock flow asynchronously (sends IPC events) + void executeMockTaskFlow(window, { + taskId, + prompt: validatedConfig.prompt, + scenario, + delayMs: 50, + }); + + return mockTask; + } + + // Setup event forwarding to renderer + const forwardToRenderer = (channel: string, data: unknown) => { + if (!window.isDestroyed() && !sender.isDestroyed()) { + sender.send(channel, data); + } + }; + + // Create task-scoped callbacks for the TaskManager + const callbacks: TaskCallbacks = { + onMessage: (message: OpenCodeMessage) => { + const taskMessage = toTaskMessage(message); + if (!taskMessage) return; + + // Queue message for batching instead of immediate send + queueMessage(taskId, taskMessage, forwardToRenderer, addTaskMessage); + }, + + onProgress: (progress: { stage: string; message?: string }) => { + forwardToRenderer('task:progress', { + taskId, + ...progress, + }); + }, + + onPermissionRequest: (request: unknown) => { + // Flush pending messages before showing permission request + flushAndCleanupBatcher(taskId); + forwardToRenderer('permission:request', request); + }, + + onComplete: (result: TaskResult) => { + // Flush any pending messages before completing + flushAndCleanupBatcher(taskId); + + forwardToRenderer('task:update', { + taskId, + type: 'complete', + result, + }); + + // Map result status to task status + let taskStatus: TaskStatus; + if (result.status === 'success') { + taskStatus = 'completed'; + } else if (result.status === 'interrupted') { + taskStatus = 'interrupted'; + } else { + taskStatus = 'failed'; + } + + // Update task status in history + updateTaskStatus(taskId, taskStatus, new Date().toISOString()); + + // Update session ID if available (important for interrupted tasks to allow continuation) + const sessionId = result.sessionId || taskManager.getSessionId(taskId); + if (sessionId) { + updateTaskSessionId(taskId, sessionId); + } + + if (result.status !== 'error') { + const storedTask = getTask(taskId); + if (storedTask) { + void rememberTask(storedTask); + } + } + }, + + onError: (error: Error) => { + // Flush any pending messages before error + flushAndCleanupBatcher(taskId); + + forwardToRenderer('task:update', { + taskId, + type: 'error', + error: error.message, + }); + + // Update task status in history + updateTaskStatus(taskId, 'failed', new Date().toISOString()); + }, + + onDebug: (log: { type: string; message: string; data?: unknown }) => { + if (getDebugMode()) { + forwardToRenderer('debug:log', { + taskId, + timestamp: new Date().toISOString(), + ...log, + }); + } + }, + + onStatusChange: (status: TaskStatus) => { + // Notify renderer of status change (e.g., queued -> running) + forwardToRenderer('task:status-change', { + taskId, + status, + }); + // Update task status in history + updateTaskStatus(taskId, status, new Date().toISOString()); + }, + }; + + // Start the task via TaskManager (creates isolated adapter or queues if busy) + const task = await taskManager.startTask(taskId, configWithMemory, callbacks); + + // Add initial user message with the prompt to the chat + const initialUserMessage: TaskMessage = { + id: createMessageId(), + type: 'user', + content: validatedConfig.prompt, + timestamp: new Date().toISOString(), + }; + task.messages = [initialUserMessage]; + + // Save task to history (includes the initial user message) + saveTask(task); + + // Generate AI summary asynchronously (don't block task execution) + generateTaskSummary(validatedConfig.prompt) + .then((summary) => { + updateTaskSummary(taskId, summary); + forwardToRenderer('task:summary', { taskId, summary }); + }) + .catch((err) => { + console.warn('[IPC] Failed to generate task summary:', err); + }); + + return task; + }); + + // Task: Cancel current task (running or queued) + handle('task:cancel', async (_event: IpcMainInvokeEvent, taskId?: string) => { + if (!taskId) return; + + // Check if it's a queued task first + if (taskManager.isTaskQueued(taskId)) { + taskManager.cancelQueuedTask(taskId); + updateTaskStatus(taskId, 'cancelled', new Date().toISOString()); + return; + } + + // Otherwise cancel the running task + if (taskManager.hasActiveTask(taskId)) { + await taskManager.cancelTask(taskId); + updateTaskStatus(taskId, 'cancelled', new Date().toISOString()); + } + }); + + // Task: Interrupt current task (graceful Ctrl+C, doesn't kill process) + handle('task:interrupt', async (_event: IpcMainInvokeEvent, taskId?: string) => { + if (!taskId) return; + + if (taskManager.hasActiveTask(taskId)) { + await taskManager.interruptTask(taskId); + // Note: Don't change task status - task is still running, just interrupted + console.log(`[IPC] Task ${taskId} interrupted`); + } + }); + + // Task: Get task from history + handle('task:get', async (_event: IpcMainInvokeEvent, taskId: string) => { + return getTask(taskId) || null; + }); + + // Task: List tasks from history + handle('task:list', async (_event: IpcMainInvokeEvent) => { + return getTasks(); + }); + + // Task: Delete task from history + handle('task:delete', async (_event: IpcMainInvokeEvent, taskId: string) => { + deleteTask(taskId); + }); + + // Task: Clear all history + handle('task:clear-history', async (_event: IpcMainInvokeEvent) => { + clearHistory(); + }); + + // Permission: Respond to permission request + handle('permission:respond', async (_event: IpcMainInvokeEvent, response: PermissionResponse) => { + const parsedResponse = validate(permissionResponseSchema, response); + const { taskId, decision, requestId } = parsedResponse; + + // Check if this is a file permission request from the MCP server + if (requestId && isFilePermissionRequest(requestId)) { + const allowed = decision === 'allow'; + const resolved = resolvePermission(requestId, allowed); + if (resolved) { + console.log(`[IPC] File permission request ${requestId} resolved: ${allowed ? 'allowed' : 'denied'}`); + return; + } + // If not found in pending, fall through to standard handling + console.warn(`[IPC] File permission request ${requestId} not found in pending requests`); + } + + // Check if this is a question request from the MCP server + if (requestId && isQuestionRequest(requestId)) { + const denied = decision === 'deny'; + const resolved = resolveQuestion(requestId, { + selectedOptions: parsedResponse.selectedOptions, + customText: parsedResponse.customText, + denied, + }); + if (resolved) { + console.log(`[IPC] Question request ${requestId} resolved: ${denied ? 'denied' : 'answered'}`); + return; + } + // If not found in pending, fall through to standard handling + console.warn(`[IPC] Question request ${requestId} not found in pending requests`); + } + + // Check if the task is still active + if (!taskManager.hasActiveTask(taskId)) { + console.warn(`[IPC] Permission response for inactive task ${taskId}`); + return; + } + + if (decision === 'allow') { + // Send the response to the correct task's CLI + const message = parsedResponse.selectedOptions?.join(', ') || parsedResponse.message || 'yes'; + const sanitizedMessage = sanitizeString(message, 'permissionResponse', 1024); + await taskManager.sendResponse(taskId, sanitizedMessage); + } else { + // Send denial to the correct task + await taskManager.sendResponse(taskId, 'no'); + } + }); + + // Session: Resume (continue conversation) + handle('session:resume', async (event: IpcMainInvokeEvent, sessionId: string, prompt: string, existingTaskId?: string) => { + const window = assertTrustedWindow(BrowserWindow.fromWebContents(event.sender)); + const sender = event.sender; + const validatedSessionId = sanitizeString(sessionId, 'sessionId', 128); + const validatedPrompt = sanitizeString(prompt, 'prompt'); + const validatedExistingTaskId = existingTaskId + ? sanitizeString(existingTaskId, 'taskId', 128) + : undefined; + + // Use existing task ID or create a new one + const taskId = validatedExistingTaskId || createTaskId(); + + // Persist the user's follow-up message to task history + if (validatedExistingTaskId) { + const userMessage: TaskMessage = { + id: createMessageId(), + type: 'user', + content: validatedPrompt, + timestamp: new Date().toISOString(), + }; + addTaskMessage(validatedExistingTaskId, userMessage); + } + + // Setup event forwarding to renderer + const forwardToRenderer = (channel: string, data: unknown) => { + if (!window.isDestroyed() && !sender.isDestroyed()) { + sender.send(channel, data); + } + }; + + // Create task-scoped callbacks for the TaskManager (with batching for performance) + const callbacks: TaskCallbacks = { + onMessage: (message: OpenCodeMessage) => { + const taskMessage = toTaskMessage(message); + if (!taskMessage) return; + + // Queue message for batching instead of immediate send + queueMessage(taskId, taskMessage, forwardToRenderer, addTaskMessage); + }, + + onProgress: (progress: { stage: string; message?: string }) => { + forwardToRenderer('task:progress', { + taskId, + ...progress, + }); + }, + + onPermissionRequest: (request: unknown) => { + // Flush pending messages before showing permission request + flushAndCleanupBatcher(taskId); + forwardToRenderer('permission:request', request); + }, + + onComplete: (result: TaskResult) => { + // Flush any pending messages before completing + flushAndCleanupBatcher(taskId); + + forwardToRenderer('task:update', { + taskId, + type: 'complete', + result, + }); + + // Map result status to task status + let taskStatus: TaskStatus; + if (result.status === 'success') { + taskStatus = 'completed'; + } else if (result.status === 'interrupted') { + taskStatus = 'interrupted'; + } else { + taskStatus = 'failed'; + } + + // Update task status in history + updateTaskStatus(taskId, taskStatus, new Date().toISOString()); + + // Update session ID if available (important for interrupted tasks to allow continuation) + const newSessionId = result.sessionId || taskManager.getSessionId(taskId); + if (newSessionId) { + updateTaskSessionId(taskId, newSessionId); + } + + if (result.status !== 'error') { + const storedTask = getTask(taskId); + if (storedTask) { + void rememberTask(storedTask); + } + } + }, + + onError: (error: Error) => { + // Flush any pending messages before error + flushAndCleanupBatcher(taskId); + + forwardToRenderer('task:update', { + taskId, + type: 'error', + error: error.message, + }); + + // Update task status in history + updateTaskStatus(taskId, 'failed', new Date().toISOString()); + }, + + onDebug: (log: { type: string; message: string; data?: unknown }) => { + if (getDebugMode()) { + forwardToRenderer('debug:log', { + taskId, + timestamp: new Date().toISOString(), + ...log, + }); + } + }, + + onStatusChange: (status: TaskStatus) => { + // Notify renderer of status change (e.g., queued -> running) + forwardToRenderer('task:status-change', { + taskId, + status, + }); + // Update task status in history + updateTaskStatus(taskId, status, new Date().toISOString()); + }, + }; + + const memoryContext = await getMemoryContextForPrompt(validatedPrompt, taskId); + const taskConfigWithMemory = applyMemoryContext( + { + prompt: validatedPrompt, + sessionId: validatedSessionId, + taskId, + }, + memoryContext + ); + + // Start the task via TaskManager with sessionId for resume (creates isolated adapter or queues if busy) + const task = await taskManager.startTask(taskId, taskConfigWithMemory, callbacks); + + // Update task status in history (whether running or queued) + if (validatedExistingTaskId) { + updateTaskStatus(validatedExistingTaskId, task.status, new Date().toISOString()); + } + + return task; + }); + + // Settings: Get API keys + // Note: In production, this should fetch from backend to get metadata + // The actual keys are stored locally in secure storage + handle('settings:api-keys', async (_event: IpcMainInvokeEvent) => { + const storedCredentials = await listStoredCredentials(); + + return storedCredentials + .filter((credential) => credential.account.startsWith('apiKey:')) + .map((credential) => { + const provider = credential.account.replace('apiKey:', ''); + + // Handle Bedrock specially - it stores JSON credentials + let keyPrefix = ''; + if (provider === 'bedrock') { + try { + const parsed = JSON.parse(credential.password); + if (parsed.authType === 'accessKeys') { + keyPrefix = `${parsed.accessKeyId?.substring(0, 8) || 'AKIA'}...`; + } else if (parsed.authType === 'profile') { + keyPrefix = `Profile: ${parsed.profileName || 'default'}`; + } + } catch { + keyPrefix = 'AWS Credentials'; + } + } else { + keyPrefix = + credential.password && credential.password.length > 0 + ? `${credential.password.substring(0, 8)}...` + : ''; + } + + return { + id: `local-${provider}`, + provider, + label: provider === 'bedrock' ? 'AWS Credentials' : 'Local API Key', + keyPrefix, + isActive: true, + createdAt: new Date().toISOString(), + }; + }); + }); + + // Settings: Add API key (stores securely in OS keychain) + handle( + 'settings:add-api-key', + async (_event: IpcMainInvokeEvent, provider: string, key: string, label?: string) => { + if (!ALLOWED_API_KEY_PROVIDERS.has(provider)) { + throw new Error('Unsupported API key provider'); + } + const sanitizedKey = sanitizeString(key, 'apiKey', 256); + const sanitizedLabel = label ? sanitizeString(label, 'label', 128) : undefined; + + // Store the API key securely in OS keychain + await storeApiKey(provider, sanitizedKey); + + return { + id: `local-${provider}`, + provider, + label: sanitizedLabel || 'Local API Key', + keyPrefix: sanitizedKey.substring(0, 8) + '...', + isActive: true, + createdAt: new Date().toISOString(), + }; + } + ); + + // Settings: Remove API key + handle('settings:remove-api-key', async (_event: IpcMainInvokeEvent, id: string) => { + // Extract provider from id (format: local-{provider}) + const sanitizedId = sanitizeString(id, 'id', 128); + const provider = sanitizedId.replace('local-', ''); + await deleteApiKey(provider); + }); + + // API Key: Check if API key exists + handle('api-key:exists', async (_event: IpcMainInvokeEvent) => { + const apiKey = await getApiKey('anthropic'); + return Boolean(apiKey); + }); + + // API Key: Set API key + handle('api-key:set', async (_event: IpcMainInvokeEvent, key: string) => { + const sanitizedKey = sanitizeString(key, 'apiKey', 256); + await storeApiKey('anthropic', sanitizedKey); + console.log('[API Key] Key set', { keyPrefix: sanitizedKey.substring(0, 8) }); + }); + + // API Key: Get API key + handle('api-key:get', async (_event: IpcMainInvokeEvent) => { + return getApiKey('anthropic'); + }); + + // API Key: Validate API key by making a test request + handle('api-key:validate', async (_event: IpcMainInvokeEvent, key: string) => { + const sanitizedKey = sanitizeString(key, 'apiKey', 256); + console.log('[API Key] Validation requested'); + + try { + // Make a simple API call to validate the key + const response = await fetchWithTimeout( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': sanitizedKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + max_tokens: 1, + messages: [{ role: 'user', content: 'test' }], + }), + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + + if (response.ok) { + console.log('[API Key] Validation succeeded'); + return { valid: true }; + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = (errorData as { error?: { message?: string } })?.error?.message || `API returned status ${response.status}`; + + console.warn('[API Key] Validation failed', { status: response.status, error: errorMessage }); + + return { valid: false, error: errorMessage }; + } catch (error) { + console.error('[API Key] Validation error', { error: error instanceof Error ? error.message : String(error) }); + if (error instanceof Error && error.name === 'AbortError') { + return { valid: false, error: 'Request timed out. Please check your internet connection and try again.' }; + } + return { valid: false, error: 'Failed to validate API key. Check your internet connection.' }; + } + }); + + // API Key: Validate API key for any provider + handle('api-key:validate-provider', async (_event: IpcMainInvokeEvent, provider: string, key: string) => { + if (!ALLOWED_API_KEY_PROVIDERS.has(provider)) { + return { valid: false, error: 'Unsupported provider' }; + } + const sanitizedKey = sanitizeString(key, 'apiKey', 256); + console.log(`[API Key] Validation requested for provider: ${provider}`); + + try { + let response: Response; + + switch (provider) { + case 'anthropic': + response = await fetchWithTimeout( + 'https://api.anthropic.com/v1/messages', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': sanitizedKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-haiku-20240307', + max_tokens: 1, + messages: [{ role: 'user', content: 'test' }], + }), + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + case 'openai': + response = await fetchWithTimeout( + 'https://api.openai.com/v1/models', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${sanitizedKey}`, + }, + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + case 'openrouter': + response = await fetchWithTimeout( + 'https://openrouter.ai/api/v1/models', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${sanitizedKey}`, + }, + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + case 'google': + response = await fetchWithTimeout( + `https://generativelanguage.googleapis.com/v1beta/models?key=${sanitizedKey}`, + { + method: 'GET', + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + case 'xai': + response = await fetchWithTimeout( + 'https://api.x.ai/v1/models', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${sanitizedKey}`, + }, + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + case 'deepseek': + response = await fetchWithTimeout( + 'https://api.deepseek.com/models', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${sanitizedKey}`, + }, + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + // Z.AI Coding Plan uses the same validation as standard API + case 'zai': + response = await fetchWithTimeout( + 'https://open.bigmodel.cn/api/paas/v4/models', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${sanitizedKey}`, + }, + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + break; + + default: + // For 'custom' provider, skip validation + console.log('[API Key] Skipping validation for custom provider'); + return { valid: true }; + } + + if (response.ok) { + console.log(`[API Key] Validation succeeded for ${provider}`); + return { valid: true }; + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = (errorData as { error?: { message?: string } })?.error?.message || `API returned status ${response.status}`; + + console.warn(`[API Key] Validation failed for ${provider}`, { status: response.status, error: errorMessage }); + return { valid: false, error: errorMessage }; + } catch (error) { + console.error(`[API Key] Validation error for ${provider}`, { error: error instanceof Error ? error.message : String(error) }); + if (error instanceof Error && error.name === 'AbortError') { + return { valid: false, error: 'Request timed out. Please check your internet connection and try again.' }; + } + return { valid: false, error: 'Failed to validate API key. Check your internet connection.' }; + } + }); + + // Bedrock: Validate AWS credentials + handle('bedrock:validate', async (_event: IpcMainInvokeEvent, credentials: string) => { + console.log('[Bedrock] Validation requested'); + + try { + const parsed = JSON.parse(credentials); + let client: BedrockClient; + + if (parsed.authType === 'accessKeys') { + // Access key authentication + const awsCredentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } = { + accessKeyId: parsed.accessKeyId, + secretAccessKey: parsed.secretAccessKey, + }; + if (parsed.sessionToken) { + awsCredentials.sessionToken = parsed.sessionToken; + } + client = new BedrockClient({ + region: parsed.region || 'us-east-1', + credentials: awsCredentials, + }); + } else if (parsed.authType === 'profile') { + // AWS Profile authentication + client = new BedrockClient({ + region: parsed.region || 'us-east-1', + credentials: fromIni({ profile: parsed.profileName || 'default' }), + }); + } else { + return { valid: false, error: 'Invalid authentication type' }; + } + + // Test by listing foundation models + const command = new ListFoundationModelsCommand({}); + await client.send(command); + + console.log('[Bedrock] Validation succeeded'); + return { valid: true }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Validation failed'; + console.warn('[Bedrock] Validation failed:', message); + + // Provide user-friendly error messages + if (message.includes('UnrecognizedClientException') || message.includes('InvalidSignatureException')) { + return { valid: false, error: 'Invalid AWS credentials. Please check your Access Key ID and Secret Access Key.' }; + } + if (message.includes('AccessDeniedException')) { + return { valid: false, error: 'Access denied. Ensure your AWS credentials have Bedrock permissions.' }; + } + if (message.includes('could not be found')) { + return { valid: false, error: 'AWS profile not found. Check your ~/.aws/credentials file.' }; + } + + return { valid: false, error: message }; + } + }); + + // Bedrock: Save credentials + handle('bedrock:save', async (_event: IpcMainInvokeEvent, credentials: string) => { + const parsed = JSON.parse(credentials); + + // Validate structure + if (parsed.authType === 'accessKeys') { + if (!parsed.accessKeyId || !parsed.secretAccessKey) { + throw new Error('Access Key ID and Secret Access Key are required'); + } + } else if (parsed.authType === 'profile') { + if (!parsed.profileName) { + throw new Error('Profile name is required'); + } + } else { + throw new Error('Invalid authentication type'); + } + + // Store the credentials + storeApiKey('bedrock', credentials); + + return { + id: 'local-bedrock', + provider: 'bedrock', + label: parsed.authType === 'accessKeys' ? 'AWS Access Keys' : `AWS Profile: ${parsed.profileName}`, + keyPrefix: parsed.authType === 'accessKeys' ? `${parsed.accessKeyId.substring(0, 8)}...` : parsed.profileName, + isActive: true, + createdAt: new Date().toISOString(), + }; + }); + + // Bedrock: Get credentials + handle('bedrock:get-credentials', async (_event: IpcMainInvokeEvent) => { + const stored = getApiKey('bedrock'); + if (!stored) return null; + try { + return JSON.parse(stored); + } catch { + return null; + } + }); + + // API Key: Clear API key + handle('api-key:clear', async (_event: IpcMainInvokeEvent) => { + await deleteApiKey('anthropic'); + console.log('[API Key] Key cleared'); + }); + + // OpenCode CLI: Check if installed + handle('opencode:check', async (_event: IpcMainInvokeEvent) => { + // E2E test bypass: return mock CLI status when E2E skip auth is enabled + if (isE2ESkipAuthEnabled()) { + return { + installed: true, + version: '1.0.0-test', + installCommand: 'npm install -g opencode-ai', + }; + } + + const installed = await isOpenCodeCliInstalled(); + const version = installed ? await getOpenCodeCliVersion() : null; + return { + installed, + version, + installCommand: 'npm install -g opencode-ai', + }; + }); + + // OpenCode CLI: Get version + handle('opencode:version', async (_event: IpcMainInvokeEvent) => { + return getOpenCodeCliVersion(); + }); + + // Model: Get selected model + handle('model:get', async (_event: IpcMainInvokeEvent) => { + return getSelectedModel(); + }); + + // Model: Set selected model + handle('model:set', async (_event: IpcMainInvokeEvent, model: SelectedModel) => { + if (!model || typeof model.provider !== 'string' || typeof model.model !== 'string') { + throw new Error('Invalid model configuration'); + } + setSelectedModel(model); + }); + + // Ollama: Test connection and get models + handle('ollama:test-connection', async (_event: IpcMainInvokeEvent, url: string) => { + const sanitizedUrl = sanitizeString(url, 'ollamaUrl', 256); + + // Validate URL format and protocol + try { + const parsed = new URL(sanitizedUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { success: false, error: 'Only http and https URLs are allowed' }; + } + } catch { + return { success: false, error: 'Invalid URL format' }; + } + + try { + const response = await fetchWithTimeout( + `${sanitizedUrl}/api/tags`, + { method: 'GET' }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + + if (!response.ok) { + throw new Error(`Ollama returned status ${response.status}`); + } + + const data = await response.json() as { models?: Array<{ name: string; size: number }> }; + const models: OllamaModel[] = (data.models || []).map((m) => ({ + id: m.name, + displayName: m.name, + size: m.size, + })); + + console.log(`[Ollama] Connection successful, found ${models.length} models`); + return { success: true, models }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Connection failed'; + console.warn('[Ollama] Connection failed:', message); + + if (error instanceof Error && error.name === 'AbortError') { + return { success: false, error: 'Connection timed out. Make sure Ollama is running.' }; + } + return { success: false, error: `Cannot connect to Ollama: ${message}` }; + } + }); + + // Ollama: Get stored config + handle('ollama:get-config', async (_event: IpcMainInvokeEvent) => { + return getOllamaConfig(); + }); + + // Ollama: Set config + handle('ollama:set-config', async (_event: IpcMainInvokeEvent, config: OllamaConfig | null) => { + if (config !== null) { + if (typeof config.baseUrl !== 'string' || typeof config.enabled !== 'boolean') { + throw new Error('Invalid Ollama configuration'); + } + // Validate URL format and protocol + try { + const parsed = new URL(config.baseUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Only http and https URLs are allowed'); + } + } catch (e) { + if (e instanceof Error && e.message.includes('http')) { + throw e; // Re-throw our protocol error + } + throw new Error('Invalid base URL format'); + } + // Validate optional lastValidated if present + if (config.lastValidated !== undefined && typeof config.lastValidated !== 'number') { + throw new Error('Invalid Ollama configuration'); + } + // Validate optional models array if present + if (config.models !== undefined) { + if (!Array.isArray(config.models)) { + throw new Error('Invalid Ollama configuration: models must be an array'); + } + for (const model of config.models) { + if (typeof model.id !== 'string' || typeof model.displayName !== 'string' || typeof model.size !== 'number') { + throw new Error('Invalid Ollama configuration: invalid model format'); + } + } + } + } + setOllamaConfig(config); + console.log('[Ollama] Config saved:', config); + }); + + // OpenRouter: Fetch available models + handle('openrouter:fetch-models', async (_event: IpcMainInvokeEvent) => { + const apiKey = getApiKey('openrouter'); + if (!apiKey) { + return { success: false, error: 'No OpenRouter API key configured' }; + } + + try { + const response = await fetchWithTimeout( + 'https://openrouter.ai/api/v1/models', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${apiKey}`, + }, + }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = (errorData as { error?: { message?: string } })?.error?.message || `API returned status ${response.status}`; + return { success: false, error: errorMessage }; + } + + const data = await response.json() as { data?: Array<{ id: string; name: string; context_length?: number }> }; + const models = (data.data || []).map((m) => { + // Extract provider from model ID (e.g., "anthropic/claude-3.5-sonnet" -> "anthropic") + const provider = m.id.split('/')[0] || 'unknown'; + return { + id: m.id, + name: m.name || m.id, + provider, + contextLength: m.context_length || 0, + }; + }); + + console.log(`[OpenRouter] Fetched ${models.length} models`); + return { success: true, models }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch models'; + console.warn('[OpenRouter] Fetch failed:', message); + + if (error instanceof Error && error.name === 'AbortError') { + return { success: false, error: 'Request timed out. Check your internet connection.' }; + } + return { success: false, error: `Failed to fetch models: ${message}` }; + } + }); + + // LiteLLM: Test connection and fetch models + handle('litellm:test-connection', async (_event: IpcMainInvokeEvent, url: string, apiKey?: string) => { + const sanitizedUrl = sanitizeString(url, 'litellmUrl', 256); + const sanitizedApiKey = apiKey ? sanitizeString(apiKey, 'apiKey', 256) : undefined; + + // Validate URL format and protocol + try { + const parsed = new URL(sanitizedUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { success: false, error: 'Only http and https URLs are allowed' }; + } + } catch { + return { success: false, error: 'Invalid URL format' }; + } + + try { + const headers: Record = {}; + if (sanitizedApiKey) { + headers['Authorization'] = `Bearer ${sanitizedApiKey}`; + } + + const response = await fetchWithTimeout( + `${sanitizedUrl}/v1/models`, + { method: 'GET', headers }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = (errorData as { error?: { message?: string } })?.error?.message || `API returned status ${response.status}`; + return { success: false, error: errorMessage }; + } + + const data = await response.json() as { data?: Array<{ id: string; object: string; created?: number; owned_by?: string }> }; + const models = (data.data || []).map((m) => { + // Extract provider from model ID (e.g., "openai/gpt-4" -> "openai") + const provider = m.id.split('/')[0] || m.owned_by || 'unknown'; + return { + id: m.id, + name: m.id, // LiteLLM uses id as name + provider, + contextLength: 0, // LiteLLM doesn't provide this in /v1/models + }; + }); + + console.log(`[LiteLLM] Connection successful, found ${models.length} models`); + return { success: true, models }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Connection failed'; + console.warn('[LiteLLM] Connection failed:', message); + + if (error instanceof Error && error.name === 'AbortError') { + return { success: false, error: 'Connection timed out. Make sure LiteLLM proxy is running.' }; + } + return { success: false, error: `Cannot connect to LiteLLM: ${message}` }; + } + }); + + // LiteLLM: Fetch models from configured proxy + handle('litellm:fetch-models', async (_event: IpcMainInvokeEvent) => { + const config = getLiteLLMConfig(); + if (!config || !config.baseUrl) { + return { success: false, error: 'No LiteLLM proxy configured' }; + } + + const apiKey = getApiKey('litellm'); + + try { + const headers: Record = {}; + if (apiKey) { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + const response = await fetchWithTimeout( + `${config.baseUrl}/v1/models`, + { method: 'GET', headers }, + API_KEY_VALIDATION_TIMEOUT_MS + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = (errorData as { error?: { message?: string } })?.error?.message || `API returned status ${response.status}`; + return { success: false, error: errorMessage }; + } + + const data = await response.json() as { data?: Array<{ id: string; object: string; created?: number; owned_by?: string }> }; + const models = (data.data || []).map((m) => { + // Extract provider from model ID (e.g., "anthropic/claude-sonnet" -> "anthropic") + const parts = m.id.split('/'); + const provider = parts.length > 1 ? parts[0] : (m.owned_by !== 'openai' ? m.owned_by : 'unknown') || 'unknown'; + + // Generate display name (e.g., "anthropic/claude-sonnet" -> "Anthropic: Claude Sonnet") + const modelPart = parts.length > 1 ? parts.slice(1).join('/') : m.id; + const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1); + const modelDisplay = modelPart + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + const displayName = parts.length > 1 ? `${providerDisplay}: ${modelDisplay}` : modelDisplay; + + return { + id: m.id, + name: displayName, + provider, + contextLength: 0, + }; + }); + + console.log(`[LiteLLM] Fetched ${models.length} models`); + return { success: true, models }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch models'; + console.warn('[LiteLLM] Fetch failed:', message); + + if (error instanceof Error && error.name === 'AbortError') { + return { success: false, error: 'Request timed out. Check your LiteLLM proxy.' }; + } + return { success: false, error: `Failed to fetch models: ${message}` }; + } + }); + + // LiteLLM: Get stored config + handle('litellm:get-config', async (_event: IpcMainInvokeEvent) => { + return getLiteLLMConfig(); + }); + + // LiteLLM: Set config + handle('litellm:set-config', async (_event: IpcMainInvokeEvent, config: LiteLLMConfig | null) => { + if (config !== null) { + if (typeof config.baseUrl !== 'string' || typeof config.enabled !== 'boolean') { + throw new Error('Invalid LiteLLM configuration'); + } + // Validate URL format and protocol + try { + const parsed = new URL(config.baseUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Only http and https URLs are allowed'); + } + } catch (e) { + if (e instanceof Error && e.message.includes('http')) { + throw e; // Re-throw our protocol error + } + throw new Error('Invalid base URL format'); + } + // Validate optional lastValidated if present + if (config.lastValidated !== undefined && typeof config.lastValidated !== 'number') { + throw new Error('Invalid LiteLLM configuration'); + } + // Validate optional models array if present + if (config.models !== undefined) { + if (!Array.isArray(config.models)) { + throw new Error('Invalid LiteLLM configuration: models must be an array'); + } + for (const model of config.models) { + if (typeof model.id !== 'string' || typeof model.name !== 'string' || typeof model.provider !== 'string') { + throw new Error('Invalid LiteLLM configuration: invalid model format'); + } + } + } + } + setLiteLLMConfig(config); + console.log('[LiteLLM] Config saved:', config); + }); + + // API Keys: Get all API keys (with masked values) + handle('api-keys:all', async (_event: IpcMainInvokeEvent) => { + const keys = await getAllApiKeys(); + // Return masked versions for UI + const masked: Record = {}; + for (const [provider, key] of Object.entries(keys)) { + masked[provider] = { + exists: Boolean(key), + prefix: key ? key.substring(0, 8) + '...' : undefined, + }; + } + return masked; + }); + + // API Keys: Check if any key exists + handle('api-keys:has-any', async (_event: IpcMainInvokeEvent) => { + // In E2E mock mode, pretend we have API keys + if (isMockTaskEventsEnabled()) { + return true; + } + return hasAnyApiKey(); + }); + + // Settings: Get debug mode setting + handle('settings:debug-mode', async (_event: IpcMainInvokeEvent) => { + return getDebugMode(); + }); + + // Settings: Set debug mode setting + handle('settings:set-debug-mode', async (_event: IpcMainInvokeEvent, enabled: boolean) => { + if (typeof enabled !== 'boolean') { + throw new Error('Invalid debug mode flag'); + } + setDebugMode(enabled); + // Broadcast the change to all renderer windows + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('settings:debug-mode-changed', { enabled }); + } + }); + + // Settings: Get all app settings + handle('settings:app-settings', async (_event: IpcMainInvokeEvent) => { + return getAppSettings(); + }); + + // Memory: Get MemOS status + handle('memory:get-config', async (_event: IpcMainInvokeEvent) => { + const apiKey = getApiKey('memos'); + return { + hasApiKey: Boolean(apiKey), + apiKeyPrefix: apiKey ? `${apiKey.substring(0, 8)}...` : undefined, + }; + }); + + // Memory: Set MemOS API key + handle('memory:set-api-key', async (_event: IpcMainInvokeEvent, key: string) => { + const sanitizedKey = sanitizeString(key, 'memosApiKey', 512); + storeApiKey('memos', sanitizedKey); + }); + + // Memory: Clear MemOS API key + handle('memory:clear-api-key', async (_event: IpcMainInvokeEvent) => { + deleteApiKey('memos'); + }); + + // Onboarding: Get onboarding complete status + // Also checks for existing task history to handle upgrades from pre-onboarding versions + handle('onboarding:complete', async (_event: IpcMainInvokeEvent) => { + // E2E test bypass: skip onboarding when E2E skip auth is enabled + if (isE2ESkipAuthEnabled()) { + return true; + } + + // If onboarding is already marked complete, return true + if (getOnboardingComplete()) { + return true; + } + + // Check if this is an existing user (has task history) + // If so, mark onboarding as complete and skip the wizard + const tasks = getTasks(); + if (tasks.length > 0) { + setOnboardingComplete(true); + return true; + } + + return false; + }); + + // Onboarding: Set onboarding complete status + handle('onboarding:set-complete', async (_event: IpcMainInvokeEvent, complete: boolean) => { + setOnboardingComplete(complete); + }); + + // Shell: Open URL in external browser + // Only allows http/https URLs for security + handle('shell:open-external', async (_event: IpcMainInvokeEvent, url: string) => { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Only http and https URLs are allowed'); + } + await shell.openExternal(url); + } catch (error) { + console.error('Failed to open external URL:', error); + throw error; + } + }); + + // Log event handler - now just returns ok (no external logging) + handle( + 'log:event', + async (_event: IpcMainInvokeEvent, _payload: { level?: string; message?: string; context?: Record }) => { + // No-op: external logging removed + return { ok: true }; + } + ); +} + +function createTaskId(): string { + return `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function createMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Extract base64 screenshots from tool output + * Returns cleaned text (with images replaced by placeholders) and extracted attachments + */ +function extractScreenshots(output: string): { + cleanedText: string; + attachments: Array<{ type: 'screenshot' | 'json'; data: string; label?: string }>; +} { + const attachments: Array<{ type: 'screenshot' | 'json'; data: string; label?: string }> = []; + + // Match data URLs (data:image/png;base64,...) + const dataUrlRegex = /data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+/g; + let match; + while ((match = dataUrlRegex.exec(output)) !== null) { + attachments.push({ + type: 'screenshot', + data: match[0], + label: 'Browser screenshot', + }); + } + + // Also check for raw base64 PNG (starts with iVBORw0) + // This pattern matches PNG base64 that isn't already a data URL + const rawBase64Regex = /(? 100) { + attachments.push({ + type: 'screenshot', + data: `data:image/png;base64,${base64Data}`, + label: 'Browser screenshot', + }); + } + } + + // Clean the text - replace image data with placeholder + let cleanedText = output + .replace(dataUrlRegex, '[Screenshot captured]') + .replace(rawBase64Regex, '[Screenshot captured]'); + + // Also clean up common JSON wrappers around screenshots + cleanedText = cleanedText + .replace(/"[Screenshot captured]"/g, '"[Screenshot]"') + .replace(/\[Screenshot captured\]\[Screenshot captured\]/g, '[Screenshot captured]'); + + return { cleanedText, attachments }; +} + +/** + * Sanitize tool output to remove technical details that confuse users + */ +function sanitizeToolOutput(text: string, isError: boolean): string { + let result = text; + + // Strip any remaining ANSI escape codes + result = result.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); + // Also strip any leftover escape sequences that may have been partially matched + result = result.replace(/\x1B\[2m|\x1B\[22m|\x1B\[0m/g, ''); + + // Remove WebSocket URLs + result = result.replace(/ws:\/\/[^\s\]]+/g, '[connection]'); + + // Remove "Call log:" sections and everything after + result = result.replace(/\s*Call log:[\s\S]*/i, ''); + + // Simplify common Playwright/CDP errors for users + if (isError) { + // Timeout errors: extract just the timeout duration + const timeoutMatch = result.match(/timed? ?out after (\d+)ms/i); + if (timeoutMatch) { + const seconds = Math.round(parseInt(timeoutMatch[1]) / 1000); + return `Timed out after ${seconds}s`; + } + + // "browserType.connectOverCDP: Protocol error (X): Y" → "Y" + const protocolMatch = result.match(/Protocol error \([^)]+\):\s*(.+)/i); + if (protocolMatch) { + result = protocolMatch[1].trim(); + } + + // "Error executing code: X" → just the meaningful part + result = result.replace(/^Error executing code:\s*/i, ''); + + // Clean up "browserType.connectOverCDP:" prefix + result = result.replace(/browserType\.connectOverCDP:\s*/i, ''); + + // Remove stack traces (lines starting with "at ") + result = result.replace(/\s+at\s+.+/g, ''); + + // Remove error class names like "CodeExecutionTimeoutError:" + result = result.replace(/\w+Error:\s*/g, ''); + } + + return result.trim(); +} + +function toTaskMessage(message: OpenCodeMessage): TaskMessage | null { + // OpenCode format: step_start, text, tool_call, tool_use, tool_result, step_finish + + // Handle text content + if (message.type === 'text') { + if (message.part.text) { + return { + id: createMessageId(), + type: 'assistant', + content: message.part.text, + timestamp: new Date().toISOString(), + }; + } + return null; + } + + // Handle tool calls (legacy format - just shows tool is starting) + if (message.type === 'tool_call') { + return { + id: createMessageId(), + type: 'tool', + content: `Using tool: ${message.part.tool}`, + toolName: message.part.tool, + toolInput: message.part.input, + timestamp: new Date().toISOString(), + }; + } + + // Handle tool_use messages (combined tool call + result) + if (message.type === 'tool_use') { + const toolUseMsg = message as import('@accomplish/shared').OpenCodeToolUseMessage; + const toolName = toolUseMsg.part.tool || 'unknown'; + const toolInput = toolUseMsg.part.state?.input; + const toolOutput = toolUseMsg.part.state?.output || ''; + const status = toolUseMsg.part.state?.status; + + // Only create message for completed/error status (not pending/running) + if (status === 'completed' || status === 'error') { + // Extract screenshots from tool output + const { cleanedText, attachments } = extractScreenshots(toolOutput); + + // Sanitize output - more aggressive for errors + const isError = status === 'error'; + const sanitizedText = sanitizeToolOutput(cleanedText, isError); + + // Truncate long outputs for display + const displayText = sanitizedText.length > 500 + ? sanitizedText.substring(0, 500) + '...' + : sanitizedText; + + return { + id: createMessageId(), + type: 'tool', + content: displayText || `Tool ${toolName} ${status}`, + toolName, + toolInput, + timestamp: new Date().toISOString(), + attachments: attachments.length > 0 ? attachments : undefined, + }; + } + return null; + } + + return null; +} diff --git a/openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts b/openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts new file mode 100644 index 000000000..a0ded6f58 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +export const taskConfigSchema = z.object({ + prompt: z.string().min(1, 'Prompt is required'), + taskId: z.string().optional(), + workingDirectory: z.string().optional(), + allowedTools: z.array(z.string()).optional(), + systemPromptAppend: z.string().optional(), + outputSchema: z.record(z.any()).optional(), + sessionId: z.string().optional(), + chrome: z.boolean().optional(), +}); + +export const permissionResponseSchema = z.object({ + requestId: z.string().min(1, 'Request ID is required'), + taskId: z.string().min(1, 'Task ID is required'), + decision: z.enum(['allow', 'deny']), + message: z.string().optional(), + selectedOptions: z.array(z.string()).optional(), + customText: z.string().optional(), +}); + +export const resumeSessionSchema = z.object({ + sessionId: z.string().min(1, 'Session ID is required'), + prompt: z.string().min(1, 'Prompt is required'), + existingTaskId: z.string().optional(), + chrome: z.boolean().optional(), +}); + +export function validate( + schema: TSchema, + payload: unknown +): z.infer { + const result = schema.safeParse(payload); + if (!result.success) { + const message = result.error.issues.map((issue: z.ZodIssue) => issue.message).join('; '); + throw new Error(`Invalid payload: ${message}`); + } + return result.data; +} + +export function normalizeIpcError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(typeof error === 'string' ? error : 'Unknown IPC error'); +} diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts b/openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts new file mode 100644 index 000000000..d208f410b --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts @@ -0,0 +1,784 @@ +import * as pty from 'node-pty'; +import { EventEmitter } from 'events'; +import { app } from 'electron'; +import fs from 'fs'; +import { StreamParser } from './stream-parser'; +import { + getOpenCodeCliPath, + isOpenCodeBundled, + getBundledOpenCodeVersion, +} from './cli-path'; +import { getAllApiKeys, getBedrockCredentials } from '../store/secureStorage'; +import { getSelectedModel } from '../store/appSettings'; +import { generateOpenCodeConfig, ACCOMPLISH_AGENT_NAME, syncApiKeysToOpenCodeAuth } from './config-generator'; +import { getExtendedNodePath } from '../utils/system-path'; +import { getBundledNodePaths, logBundledNodeInfo } from '../utils/bundled-node'; +import path from 'path'; +import type { + TaskConfig, + Task, + TaskMessage, + TaskResult, + OpenCodeMessage, + PermissionRequest, +} from '@accomplish/shared'; + +/** + * Error thrown when OpenCode CLI is not available + */ +export class OpenCodeCliNotFoundError extends Error { + constructor() { + super( + 'OpenCode CLI is not available. The bundled CLI may be missing or corrupted. Please reinstall the application.' + ); + this.name = 'OpenCodeCliNotFoundError'; + } +} + +/** + * Check if OpenCode CLI is available (bundled or installed) + */ +export async function isOpenCodeCliInstalled(): Promise { + return isOpenCodeBundled(); +} + +/** + * Get OpenCode CLI version + */ +export async function getOpenCodeCliVersion(): Promise { + return getBundledOpenCodeVersion(); +} + +export interface OpenCodeAdapterEvents { + message: [OpenCodeMessage]; + 'tool-use': [string, unknown]; + 'tool-result': [string]; + 'permission-request': [PermissionRequest]; + progress: [{ stage: string; message?: string }]; + complete: [TaskResult]; + error: [Error]; + debug: [{ type: string; message: string; data?: unknown }]; +} + +export class OpenCodeAdapter extends EventEmitter { + private ptyProcess: pty.IPty | null = null; + private streamParser: StreamParser; + private currentSessionId: string | null = null; + private currentTaskId: string | null = null; + private messages: TaskMessage[] = []; + private hasCompleted: boolean = false; + private isDisposed: boolean = false; + private wasInterrupted: boolean = false; + + /** + * Create a new OpenCodeAdapter instance + * @param taskId - Optional task ID for this adapter instance (used for logging) + */ + constructor(taskId?: string) { + super(); + this.currentTaskId = taskId || null; + this.streamParser = new StreamParser(); + this.setupStreamParsing(); + } + + /** + * Start a new task with OpenCode CLI + */ + async startTask(config: TaskConfig): Promise { + // Check if adapter has been disposed + if (this.isDisposed) { + throw new Error('Adapter has been disposed and cannot start new tasks'); + } + + // Check if OpenCode CLI is installed before attempting to start + const cliInstalled = await isOpenCodeCliInstalled(); + if (!cliInstalled) { + throw new OpenCodeCliNotFoundError(); + } + + const taskId = config.taskId || this.generateTaskId(); + this.currentTaskId = taskId; + this.currentSessionId = null; + this.messages = []; + this.streamParser.reset(); + this.hasCompleted = false; + this.wasInterrupted = false; + + // Sync API keys to OpenCode CLI's auth.json (for DeepSeek, Z.AI support) + await syncApiKeysToOpenCodeAuth(); + + // Generate OpenCode config file with MCP settings and agent + console.log('[OpenCode CLI] Generating OpenCode config with MCP settings and agent...'); + const configPath = await generateOpenCodeConfig(config.systemPromptAppend); + console.log('[OpenCode CLI] Config generated at:', configPath); + + const cliArgs = await this.buildCliArgs(config); + + // Get the bundled CLI path + const { command, args: baseArgs } = getOpenCodeCliPath(); + const startMsg = `Starting: ${command} ${[...baseArgs, ...cliArgs].join(' ')}`; + console.log('[OpenCode CLI]', startMsg); + this.emit('debug', { type: 'info', message: startMsg }); + + // Build environment with API keys + const env = await this.buildEnvironment(); + + const allArgs = [...baseArgs, ...cliArgs]; + const cmdMsg = `Command: ${command}`; + const argsMsg = `Args: ${allArgs.join(' ')}`; + // Use temp directory as default cwd to avoid TCC permission prompts. + // Home directory (~/) triggers TCC when the CLI scans for projects/configs + // because it lists Desktop, Documents, etc. + const safeCwd = config.workingDirectory || app.getPath('temp'); + const cwdMsg = `Working directory: ${safeCwd}`; + + console.log('[OpenCode CLI]', cmdMsg); + console.log('[OpenCode CLI]', argsMsg); + console.log('[OpenCode CLI]', cwdMsg); + + this.emit('debug', { type: 'info', message: cmdMsg }); + this.emit('debug', { type: 'info', message: argsMsg, data: { args: allArgs } }); + this.emit('debug', { type: 'info', message: cwdMsg }); + + // Always use PTY for proper terminal emulation + // We spawn via shell because posix_spawnp doesn't interpret shebangs + { + const fullCommand = [command, ...allArgs].map(arg => { + // Escape single quotes in arguments for shell (Unix) or handle Windows quoting + if (process.platform === 'win32') { + // Windows: use double quotes for arguments with spaces + if (arg.includes(' ') || arg.includes('"')) { + return `"${arg.replace(/"/g, '\\"')}"`; + } + return arg; + } else { + // Unix: use single quotes + if (arg.includes("'") || arg.includes(' ') || arg.includes('"')) { + return `'${arg.replace(/'/g, "'\\''")}'`; + } + return arg; + } + }).join(' '); + + const shellCmdMsg = `Full shell command: ${fullCommand}`; + console.log('[OpenCode CLI]', shellCmdMsg); + this.emit('debug', { type: 'info', message: shellCmdMsg }); + + // Use platform-appropriate shell + const shellCmd = this.getPlatformShell(); + const shellArgs = this.getShellArgs(fullCommand); + const shellMsg = `Using shell: ${shellCmd} ${shellArgs.join(' ')}`; + console.log('[OpenCode CLI]', shellMsg); + this.emit('debug', { type: 'info', message: shellMsg }); + + this.ptyProcess = pty.spawn(shellCmd, shellArgs, { + name: 'xterm-256color', + cols: 200, + rows: 30, + cwd: safeCwd, + env: env as { [key: string]: string }, + }); + const pidMsg = `PTY Process PID: ${this.ptyProcess.pid}`; + console.log('[OpenCode CLI]', pidMsg); + this.emit('debug', { type: 'info', message: pidMsg }); + + // Handle PTY data (combines stdout/stderr) + this.ptyProcess.onData((data: string) => { + // Filter out ANSI escape codes and control characters for cleaner parsing + const cleanData = data.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); + if (cleanData.trim()) { + // Truncate for console.log to avoid flooding terminal + const truncated = cleanData.substring(0, 500) + (cleanData.length > 500 ? '...' : ''); + console.log('[OpenCode CLI stdout]:', truncated); + // Send full data to debug panel + this.emit('debug', { type: 'stdout', message: cleanData }); + + this.streamParser.feed(cleanData); + } + }); + + // Handle PTY exit + this.ptyProcess.onExit(({ exitCode, signal }) => { + const exitMsg = `PTY Process exited with code: ${exitCode}, signal: ${signal}`; + console.log('[OpenCode CLI]', exitMsg); + this.emit('debug', { type: 'exit', message: exitMsg, data: { exitCode, signal } }); + this.handleProcessExit(exitCode); + }); + } + + return { + id: taskId, + prompt: config.prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + }; + } + + /** + * Resume an existing session + */ + async resumeSession(sessionId: string, prompt: string): Promise { + return this.startTask({ + prompt, + sessionId, + }); + } + + /** + * Send user response for permission/question + * Note: This requires the PTY to be active + */ + async sendResponse(response: string): Promise { + if (!this.ptyProcess) { + throw new Error('No active process'); + } + + this.ptyProcess.write(response + '\n'); + console.log('[OpenCode CLI] Response sent via PTY'); + } + + /** + * Cancel the current task (hard kill) + */ + async cancelTask(): Promise { + if (this.ptyProcess) { + // Kill the PTY process + this.ptyProcess.kill(); + this.ptyProcess = null; + } + } + + /** + * Interrupt the current task (graceful Ctrl+C) + * Sends SIGINT to allow the CLI to stop gracefully and wait for next input. + * Unlike cancelTask(), this doesn't kill the process - it just interrupts the current operation. + */ + async interruptTask(): Promise { + if (!this.ptyProcess) { + console.log('[OpenCode CLI] No active process to interrupt'); + return; + } + + // Mark as interrupted so we can handle the exit appropriately + this.wasInterrupted = true; + + // Send Ctrl+C (ASCII 0x03) to the PTY to interrupt current operation + this.ptyProcess.write('\x03'); + console.log('[OpenCode CLI] Sent Ctrl+C interrupt signal'); + } + + /** + * Get the current session ID + */ + getSessionId(): string | null { + return this.currentSessionId; + } + + /** + * Get the current task ID + */ + getTaskId(): string | null { + return this.currentTaskId; + } + + /** + * Check if the adapter has been disposed + */ + isAdapterDisposed(): boolean { + return this.isDisposed; + } + + /** + * Dispose the adapter and clean up all resources + * Called when task completes, is cancelled, or on app quit + */ + dispose(): void { + if (this.isDisposed) { + return; + } + + console.log(`[OpenCode Adapter] Disposing adapter for task ${this.currentTaskId}`); + this.isDisposed = true; + + // Kill PTY process if running + if (this.ptyProcess) { + try { + this.ptyProcess.kill(); + } catch (error) { + console.error('[OpenCode Adapter] Error killing PTY process:', error); + } + this.ptyProcess = null; + } + + // Clear state + this.currentSessionId = null; + this.currentTaskId = null; + this.messages = []; + this.hasCompleted = true; + + // Reset stream parser + this.streamParser.reset(); + + // Remove all listeners + this.removeAllListeners(); + + console.log('[OpenCode Adapter] Adapter disposed'); + } + + /** + * Build environment variables with all API keys + */ + private async buildEnvironment(): Promise { + const env: NodeJS.ProcessEnv = { + ...process.env, + }; + + if (app.isPackaged) { + // Run the bundled CLI with Electron acting as Node (no system Node required). + env.ELECTRON_RUN_AS_NODE = '1'; + + // Log bundled Node.js configuration + logBundledNodeInfo(); + + // Add bundled Node.js to PATH (highest priority) + const bundledNode = getBundledNodePaths(); + if (bundledNode) { + // Prepend bundled Node.js bin directory to PATH + const delimiter = process.platform === 'win32' ? ';' : ':'; + env.PATH = `${bundledNode.binDir}${delimiter}${env.PATH || ''}`; + // Also expose as NODE_BIN_PATH so agent can use it in bash commands + env.NODE_BIN_PATH = bundledNode.binDir; + console.log('[OpenCode CLI] Added bundled Node.js to PATH:', bundledNode.binDir); + } + + // For packaged apps on macOS, also extend PATH to include common Node.js locations as fallback. + // This avoids using login shell which triggers folder access permissions. + if (process.platform === 'darwin') { + env.PATH = getExtendedNodePath(env.PATH); + console.log('[OpenCode CLI] Extended PATH for packaged app'); + } + } + + // Load all API keys + const apiKeys = await getAllApiKeys(); + + if (apiKeys.anthropic) { + env.ANTHROPIC_API_KEY = apiKeys.anthropic; + console.log('[OpenCode CLI] Using Anthropic API key from settings'); + } + if (apiKeys.openai) { + env.OPENAI_API_KEY = apiKeys.openai; + console.log('[OpenCode CLI] Using OpenAI API key from settings'); + } + if (apiKeys.google) { + env.GOOGLE_GENERATIVE_AI_API_KEY = apiKeys.google; + console.log('[OpenCode CLI] Using Google API key from settings'); + } + if (apiKeys.xai) { + env.XAI_API_KEY = apiKeys.xai; + console.log('[OpenCode CLI] Using xAI API key from settings'); + } + if (apiKeys.deepseek) { + env.DEEPSEEK_API_KEY = apiKeys.deepseek; + console.log('[OpenCode CLI] Using DeepSeek API key from settings'); + } + if (apiKeys.zai) { + env.ZAI_API_KEY = apiKeys.zai; + console.log('[OpenCode CLI] Using Z.AI API key from settings'); + } + if (apiKeys.openrouter) { + env.OPENROUTER_API_KEY = apiKeys.openrouter; + console.log('[OpenCode CLI] Using OpenRouter API key from settings'); + } + if (apiKeys.litellm) { + env.LITELLM_API_KEY = apiKeys.litellm; + console.log('[OpenCode CLI] Using LiteLLM API key from settings'); + } + + // Set Bedrock credentials if configured + const bedrockCredentials = getBedrockCredentials(); + if (bedrockCredentials) { + if (bedrockCredentials.authType === 'accessKeys') { + env.AWS_ACCESS_KEY_ID = bedrockCredentials.accessKeyId; + env.AWS_SECRET_ACCESS_KEY = bedrockCredentials.secretAccessKey; + if (bedrockCredentials.sessionToken) { + env.AWS_SESSION_TOKEN = bedrockCredentials.sessionToken; + } + console.log('[OpenCode CLI] Using Bedrock Access Key credentials'); + } else if (bedrockCredentials.authType === 'profile') { + env.AWS_PROFILE = bedrockCredentials.profileName; + console.log('[OpenCode CLI] Using Bedrock AWS Profile:', bedrockCredentials.profileName); + } + if (bedrockCredentials.region) { + env.AWS_REGION = bedrockCredentials.region; + console.log('[OpenCode CLI] Using Bedrock region:', bedrockCredentials.region); + } + } + + // Set Ollama host if configured + const selectedModel = getSelectedModel(); + if (selectedModel?.provider === 'ollama' && selectedModel.baseUrl) { + env.OLLAMA_HOST = selectedModel.baseUrl; + console.log('[OpenCode CLI] Using Ollama host:', selectedModel.baseUrl); + } + + // Log config environment variable + console.log('[OpenCode CLI] OPENCODE_CONFIG in env:', process.env.OPENCODE_CONFIG); + if (process.env.OPENCODE_CONFIG) { + env.OPENCODE_CONFIG = process.env.OPENCODE_CONFIG; + console.log('[OpenCode CLI] Passing OPENCODE_CONFIG to subprocess:', env.OPENCODE_CONFIG); + } + + // Pass task ID to environment for task-scoped page naming in parallel execution + if (this.currentTaskId) { + env.ACCOMPLISH_TASK_ID = this.currentTaskId; + console.log('[OpenCode CLI] Task ID in environment:', this.currentTaskId); + } + + this.emit('debug', { type: 'info', message: 'Environment configured with API keys' }); + + return env; + } + + private async buildCliArgs(config: TaskConfig): Promise { + // Get selected model from settings + const selectedModel = getSelectedModel(); + + // OpenCode CLI uses: opencode run "message" --format json + const args = [ + 'run', + config.prompt, + '--format', 'json', + ]; + + // Add model selection if specified + if (selectedModel?.model) { + if (selectedModel.provider === 'zai') { + // Z.AI Coding Plan uses 'zai-coding-plan' provider in OpenCode CLI + const modelId = selectedModel.model.split('/').pop(); + args.push('--model', `zai-coding-plan/${modelId}`); + } else if (selectedModel.provider === 'deepseek') { + // DeepSeek uses 'deepseek' provider in OpenCode CLI + const modelId = selectedModel.model.split('/').pop(); + args.push('--model', `deepseek/${modelId}`); + } else if (selectedModel.provider === 'openrouter') { + // OpenRouter models use format: openrouter/provider/model + // The fullId is already in the correct format (e.g., openrouter/anthropic/claude-opus-4-5) + args.push('--model', selectedModel.model); + } else { + args.push('--model', selectedModel.model); + } + } + + // Resume session if specified + if (config.sessionId) { + args.push('--session', config.sessionId); + } + + // Use the Accomplish agent for browser automation guidance + args.push('--agent', ACCOMPLISH_AGENT_NAME); + + return args; + } + + private setupStreamParsing(): void { + this.streamParser.on('message', (message: OpenCodeMessage) => { + this.handleMessage(message); + }); + + // Handle parse errors gracefully to prevent crashes from non-JSON output + // PTY combines stdout/stderr, so shell banners, warnings, etc. may appear + this.streamParser.on('error', (error: Error) => { + // Log but don't crash - non-JSON lines are expected from PTY (shell banners, warnings, etc.) + console.warn('[OpenCode Adapter] Stream parse warning:', error.message); + this.emit('debug', { type: 'parse-warning', message: error.message }); + }); + } + + private handleMessage(message: OpenCodeMessage): void { + console.log('[OpenCode Adapter] Handling message type:', message.type); + + switch (message.type) { + // Step start event + case 'step_start': + this.currentSessionId = message.part.sessionID; + this.emit('progress', { stage: 'init', message: 'Task started' }); + break; + + // Text content event + case 'text': + if (!this.currentSessionId && message.part.sessionID) { + this.currentSessionId = message.part.sessionID; + } + this.emit('message', message); + + if (message.part.text) { + const taskMessage: TaskMessage = { + id: this.generateMessageId(), + type: 'assistant', + content: message.part.text, + timestamp: new Date().toISOString(), + }; + this.messages.push(taskMessage); + } + break; + + // Tool call event + case 'tool_call': + const toolName = message.part.tool || 'unknown'; + const toolInput = message.part.input; + + console.log('[OpenCode Adapter] Tool call:', toolName); + + this.emit('tool-use', toolName, toolInput); + this.emit('progress', { + stage: 'tool-use', + message: `Using ${toolName}`, + }); + + // Check if this is AskUserQuestion (requires user input) + if (toolName === 'AskUserQuestion') { + this.handleAskUserQuestion(toolInput as AskUserQuestionInput); + } + break; + + // Tool use event - combined tool call and result from OpenCode CLI + case 'tool_use': + const toolUseMessage = message as import('@accomplish/shared').OpenCodeToolUseMessage; + const toolUseName = toolUseMessage.part.tool || 'unknown'; + const toolUseInput = toolUseMessage.part.state?.input; + const toolUseOutput = toolUseMessage.part.state?.output || ''; + + // For models that don't emit text messages (like Gemini), emit the tool description + // as a thinking message so users can see what the AI is doing + const toolDescription = (toolUseInput as { description?: string })?.description; + if (toolDescription) { + // Create a synthetic text message for the description + const syntheticTextMessage: OpenCodeMessage = { + type: 'text', + timestamp: message.timestamp, + sessionID: message.sessionID, + part: { + id: this.generateMessageId(), + sessionID: toolUseMessage.part.sessionID, + messageID: toolUseMessage.part.messageID, + type: 'text', + text: toolDescription, + }, + } as import('@accomplish/shared').OpenCodeTextMessage; + this.emit('message', syntheticTextMessage); + } + + // Forward to handlers.ts for message processing (screenshots, etc.) + this.emit('message', message); + const toolUseStatus = toolUseMessage.part.state?.status; + + console.log('[OpenCode Adapter] Tool use:', toolUseName, 'status:', toolUseStatus); + + // Emit tool-use event for the call + this.emit('tool-use', toolUseName, toolUseInput); + this.emit('progress', { + stage: 'tool-use', + message: `Using ${toolUseName}`, + }); + + // If status is completed or error, also emit tool-result + if (toolUseStatus === 'completed' || toolUseStatus === 'error') { + this.emit('tool-result', toolUseOutput); + } + + // Check if this is AskUserQuestion (requires user input) + if (toolUseName === 'AskUserQuestion') { + this.handleAskUserQuestion(toolUseInput as AskUserQuestionInput); + } + break; + + // Tool result event + case 'tool_result': + const toolOutput = message.part.output || ''; + console.log('[OpenCode Adapter] Tool result received, length:', toolOutput.length); + this.emit('tool-result', toolOutput); + break; + + // Step finish event + case 'step_finish': + // Only complete if reason is 'stop' or 'end_turn' (final completion) + // 'tool_use' means there are more steps coming + if (message.part.reason === 'stop' || message.part.reason === 'end_turn') { + this.hasCompleted = true; + this.emit('complete', { + status: 'success', + sessionId: this.currentSessionId || undefined, + }); + } else if (message.part.reason === 'error') { + this.hasCompleted = true; + this.emit('complete', { + status: 'error', + sessionId: this.currentSessionId || undefined, + error: 'Task failed', + }); + } + // 'tool_use' reason means agent is continuing, don't emit complete + break; + + // Error event + case 'error': + this.hasCompleted = true; + this.emit('complete', { + status: 'error', + sessionId: this.currentSessionId || undefined, + error: message.error, + }); + break; + + default: + // Cast to unknown to safely access type property for logging + const unknownMessage = message as unknown as { type: string }; + console.log('[OpenCode Adapter] Unknown message type:', unknownMessage.type); + } + } + + private handleAskUserQuestion(input: AskUserQuestionInput): void { + const question = input.questions?.[0]; + if (!question) return; + + const permissionRequest: PermissionRequest = { + id: this.generateRequestId(), + taskId: this.currentTaskId || '', + type: 'question', + question: question.question, + options: question.options?.map((o) => ({ + label: o.label, + description: o.description, + })), + multiSelect: question.multiSelect, + createdAt: new Date().toISOString(), + }; + + this.emit('permission-request', permissionRequest); + } + + private handleProcessExit(code: number | null): void { + // Only emit complete/error if we haven't already received a result message + if (!this.hasCompleted) { + if (this.wasInterrupted && code === 0) { + // User interrupted the task - emit interrupted status so they can continue + console.log('[OpenCode CLI] Task was interrupted by user'); + this.emit('complete', { + status: 'interrupted', + sessionId: this.currentSessionId || undefined, + }); + } else if (code === 0) { + // Normal exit without result message + this.emit('complete', { + status: 'success', + sessionId: this.currentSessionId || undefined, + }); + } else if (code !== null) { + // Error exit + this.emit('error', new Error(`OpenCode CLI exited with code ${code}`)); + } + } + + this.ptyProcess = null; + this.currentTaskId = null; + } + + private generateTaskId(): string { + return `task_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } + + private generateMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } + + private generateRequestId(): string { + return `req_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } + + /** + * Get platform-appropriate shell command + * + * In packaged apps on macOS, we use /bin/sh instead of the user's shell + * to avoid loading ANY user config files. Even non-login zsh loads ~/.zshenv + * which may reference protected folders and trigger TCC permission dialogs. + * + * /bin/sh with -c flag doesn't load any user configuration. + */ + private getPlatformShell(): string { + if (process.platform === 'win32') { + // Use PowerShell on Windows for better compatibility + return 'powershell.exe'; + } else if (app.isPackaged && process.platform === 'darwin') { + // In packaged macOS apps, use /bin/sh to avoid loading user shell configs + // (zsh always loads ~/.zshenv, which may trigger TCC permissions) + return '/bin/sh'; + } else { + // In dev mode, use the user's shell for better compatibility + const userShell = process.env.SHELL; + if (userShell) { + return userShell; + } + // Fallback chain: bash -> zsh -> sh + if (fs.existsSync('/bin/bash')) return '/bin/bash'; + if (fs.existsSync('/bin/zsh')) return '/bin/zsh'; + return '/bin/sh'; + } + } + + /** + * Get shell arguments for running a command + * + * Note: We intentionally do NOT use login shell (-l) on macOS to avoid + * triggering folder access permissions (TCC). Login shells load ~/.zprofile + * and ~/.zshrc which may reference protected folders like Desktop/Documents. + * + * Instead, we extend PATH in buildEnvironment() using path_helper and common + * Node.js installation paths. This is the proper macOS approach for GUI apps. + */ + private getShellArgs(command: string): string[] { + if (process.platform === 'win32') { + // PowerShell: -NoProfile for faster startup, -Command to run the command + return ['-NoProfile', '-Command', command]; + } else { + // Unix shells: -c to run command (no -l to avoid profile loading) + return ['-c', command]; + } + } +} + +interface AskUserQuestionInput { + questions?: Array<{ + question: string; + header?: string; + options?: Array<{ label: string; description?: string }>; + multiSelect?: boolean; + }>; +} + +/** + * Factory function to create a new adapter instance + * Use this for the new per-task architecture via TaskManager + */ +export function createAdapter(taskId?: string): OpenCodeAdapter { + return new OpenCodeAdapter(taskId); +} + +/** + * @deprecated Use TaskManager and createAdapter() instead. + * Singleton instance kept for backward compatibility during migration. + */ +let adapterInstance: OpenCodeAdapter | null = null; + +/** + * @deprecated Use TaskManager and createAdapter() instead. + * Get the legacy singleton adapter instance. + */ +export function getOpenCodeAdapter(): OpenCodeAdapter { + if (!adapterInstance) { + adapterInstance = new OpenCodeAdapter(); + } + return adapterInstance; +} diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts b/openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts new file mode 100644 index 000000000..1ed86452d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts @@ -0,0 +1,215 @@ +import { app } from 'electron'; +import path from 'path'; +import fs from 'fs'; +import { execSync } from 'child_process'; + +/** + * Get all possible nvm OpenCode CLI paths by scanning the nvm versions directory + */ +function getNvmOpenCodePaths(): string[] { + const homeDir = process.env.HOME || ''; + const nvmVersionsDir = path.join(homeDir, '.nvm/versions/node'); + const paths: string[] = []; + + try { + if (fs.existsSync(nvmVersionsDir)) { + const versions = fs.readdirSync(nvmVersionsDir); + for (const version of versions) { + const opencodePath = path.join(nvmVersionsDir, version, 'bin', 'opencode'); + if (fs.existsSync(opencodePath)) { + paths.push(opencodePath); + } + } + } + } catch { + // Ignore errors scanning nvm directory + } + + return paths; +} + +/** + * Get the path to the bundled OpenCode CLI. + * + * In development: uses node_modules/.bin/opencode + * In packaged app: uses the bundled CLI from unpacked asar + */ +export function getOpenCodeCliPath(): { command: string; args: string[] } { + if (app.isPackaged) { + // In packaged app, OpenCode is in unpacked asar + // process.resourcesPath points to Resources folder in macOS app bundle + const cliPath = path.join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + 'opencode-ai', + 'bin', + 'opencode' + ); + + // Verify the file exists + if (!fs.existsSync(cliPath)) { + throw new Error(`OpenCode CLI not found at: ${cliPath}`); + } + + // OpenCode binary can be run directly + return { + command: cliPath, + args: [], + }; + } else { + // In development, use global opencode if available + + // Check nvm installations (dynamically scan all versions) + const nvmPaths = getNvmOpenCodePaths(); + for (const opencodePath of nvmPaths) { + console.log('[CLI Path] Using nvm OpenCode CLI:', opencodePath); + return { command: opencodePath, args: [] }; + } + + // Check other global installations + const globalOpenCodePaths = [ + // Global npm + '/usr/local/bin/opencode', + // Homebrew + '/opt/homebrew/bin/opencode', + ]; + + for (const opencodePath of globalOpenCodePaths) { + if (fs.existsSync(opencodePath)) { + console.log('[CLI Path] Using global OpenCode CLI:', opencodePath); + return { command: opencodePath, args: [] }; + } + } + + // Try bundled CLI in node_modules + // Use app.getAppPath() instead of process.cwd() as cwd is unpredictable in Electron IPC handlers + const binName = process.platform === 'win32' ? 'opencode.cmd' : 'opencode'; + const devCliPath = path.join(app.getAppPath(), 'node_modules', '.bin', binName); + if (fs.existsSync(devCliPath)) { + console.log('[CLI Path] Using bundled CLI:', devCliPath); + return { command: devCliPath, args: [] }; + } + + // Final fallback: try 'opencode' on PATH + // This handles cases where opencode is installed globally but in a non-standard location + console.log('[CLI Path] Falling back to opencode command on PATH'); + return { command: 'opencode', args: [] }; + } +} + +/** + * Check if opencode is available on the system PATH + */ +function isOpenCodeOnPath(): boolean { + try { + const command = process.platform === 'win32' ? 'where opencode' : 'which opencode'; + execSync(command, { stdio: ['pipe', 'pipe', 'pipe'] }); + return true; + } catch { + return false; + } +} + +/** + * Check if the bundled OpenCode CLI is available + */ +export function isOpenCodeBundled(): boolean { + try { + if (app.isPackaged) { + // In packaged mode, check if opencode exists + const cliPath = path.join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + 'opencode-ai', + 'bin', + 'opencode' + ); + return fs.existsSync(cliPath); + } else { + // In dev mode, actually verify the CLI exists + + // Check nvm installations (dynamically scan all versions) + const nvmPaths = getNvmOpenCodePaths(); + if (nvmPaths.length > 0) { + return true; + } + + // Check other global installations + const globalOpenCodePaths = [ + // Global npm + '/usr/local/bin/opencode', + // Homebrew + '/opt/homebrew/bin/opencode', + ]; + + for (const opencodePath of globalOpenCodePaths) { + if (fs.existsSync(opencodePath)) { + return true; + } + } + + // Check bundled CLI in node_modules + // Use app.getAppPath() instead of process.cwd() as cwd is unpredictable in Electron IPC handlers + const binName = process.platform === 'win32' ? 'opencode.cmd' : 'opencode'; + const devCliPath = path.join(app.getAppPath(), 'node_modules', '.bin', binName); + if (fs.existsSync(devCliPath)) { + return true; + } + + // Final fallback: check if opencode is available on PATH + // This handles installations in non-standard locations + if (isOpenCodeOnPath()) { + return true; + } + + // No CLI found + return false; + } + } catch { + return false; + } +} + +/** + * Get the version of the bundled OpenCode CLI + */ +export function getBundledOpenCodeVersion(): string | null { + try { + if (app.isPackaged) { + // In packaged mode, read from package.json + const packageJsonPath = path.join( + process.resourcesPath, + 'app.asar.unpacked', + 'node_modules', + 'opencode-ai', + 'package.json' + ); + + if (fs.existsSync(packageJsonPath)) { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + return pkg.version; + } + return null; + } else { + // In dev mode, run the CLI to get version + const { command, args } = getOpenCodeCliPath(); + const fullCommand = args.length > 0 + ? `"${command}" ${args.map(a => `"${a}"`).join(' ')} --version` + : `"${command}" --version`; + + const output = execSync(fullCommand, { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'] + }).trim(); + + // Parse version from output (e.g., "opencode 1.0.0" or just "1.0.0") + const versionMatch = output.match(/(\d+\.\d+\.\d+)/); + return versionMatch ? versionMatch[1] : output; + } + } catch { + return null; + } +} diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts b/openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts new file mode 100644 index 000000000..d8598f84d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts @@ -0,0 +1,728 @@ +import { app } from 'electron'; +import path from 'path'; +import fs from 'fs'; +import { PERMISSION_API_PORT, QUESTION_API_PORT } from '../permission-api'; +import { getOllamaConfig, getLiteLLMConfig } from '../store/appSettings'; +import { getApiKey } from '../store/secureStorage'; +import type { BedrockCredentials } from '@accomplish/shared'; + +/** + * Agent name used by Accomplish + */ +export const ACCOMPLISH_AGENT_NAME = 'accomplish'; + +/** + * System prompt for the Accomplish agent. + * + * Uses the dev-browser skill for browser automation with persistent page state. + * + * @see https://github.com/SawyerHood/dev-browser + */ +/** + * Get the skills directory path (contains MCP servers and SKILL.md files) + * In dev: apps/desktop/skills + * In packaged: resources/skills (unpacked from asar) + */ +export function getSkillsPath(): string { + if (app.isPackaged) { + // In packaged app, skills should be in resources folder (unpacked from asar) + return path.join(process.resourcesPath, 'skills'); + } else { + // In development, use app.getAppPath() which returns the desktop app directory + // app.getAppPath() returns apps/desktop in dev mode + return path.join(app.getAppPath(), 'skills'); + } +} + +/** + * Get the OpenCode config directory path (parent of skills/ for OPENCODE_CONFIG_DIR) + * OpenCode looks for skills at $OPENCODE_CONFIG_DIR/skills//SKILL.md + */ +export function getOpenCodeConfigDir(): string { + if (app.isPackaged) { + return process.resourcesPath; + } else { + return app.getAppPath(); + } +} + +const ACCOMPLISH_SYSTEM_PROMPT_TEMPLATE = ` +You are Accomplish, a browser automation assistant. + + + +This app bundles Node.js. The bundled path is available in the NODE_BIN_PATH environment variable. +Before running node/npx/npm commands, prepend it to PATH: + +PATH="\${NODE_BIN_PATH}:\$PATH" npx tsx script.ts + +Never assume Node.js is installed system-wide. Always use the bundled version. + + + +When users ask about your capabilities, mention: +- **Browser Automation**: Control web browsers, navigate sites, fill forms, click buttons +- **File Management**: Sort, rename, and move files based on content or rules you give it + + + +############################################################################## +# CRITICAL: FILE PERMISSION WORKFLOW - NEVER SKIP +############################################################################## + +BEFORE using Write, Edit, Bash (with file ops), or ANY tool that touches files: +1. FIRST: Call request_file_permission tool and wait for response +2. ONLY IF response is "allowed": Proceed with the file operation +3. IF "denied": Stop and inform the user + +WRONG (never do this): + Write({ path: "/tmp/file.txt", content: "..." }) ← NO! Permission not requested! + +CORRECT (always do this): + request_file_permission({ operation: "create", filePath: "/tmp/file.txt" }) + → Wait for "allowed" + Write({ path: "/tmp/file.txt", content: "..." }) ← OK after permission granted + +This applies to ALL file operations: +- Creating files (Write tool, bash echo/cat, scripts that output files) +- Renaming files (bash mv, rename commands) +- Deleting files (bash rm, delete commands) +- Modifying files (Edit tool, bash sed/awk, any content changes) + +EXCEPTION: Temp scripts in /tmp/accomplish-*.mts for browser automation are auto-allowed. +############################################################################## + + + +Use this MCP tool to request user permission before performing file operations. + + +Input: +{ + "operation": "create" | "delete" | "rename" | "move" | "modify" | "overwrite", + "filePath": "/absolute/path/to/file", + "targetPath": "/new/path", // Required for rename/move + "contentPreview": "file content" // Optional preview for create/modify/overwrite +} + +Operations: +- create: Creating a new file +- delete: Deleting an existing file or folder +- rename: Renaming a file (provide targetPath) +- move: Moving a file to different location (provide targetPath) +- modify: Modifying existing file content +- overwrite: Replacing entire file content + +Returns: "allowed" or "denied" - proceed only if allowed + + + +request_file_permission({ + operation: "create", + filePath: "/Users/john/Desktop/report.txt" +}) +// Wait for response, then proceed only if "allowed" + + + + +Browser automation that maintains page state across script executions. Write small, focused scripts to accomplish tasks incrementally. + + +############################################################################## +# MANDATORY: Browser scripts must use .mts extension to enable ESM mode. +# tsx treats .mts files as ES modules, enabling top-level await. +# +# CORRECT (always do this - two steps): +# 1. Write script to temp file with .mts extension: +# cat > /tmp/accomplish-\${ACCOMPLISH_TASK_ID:-default}.mts <<'EOF' +# import { connect } from "@/client.js"; +# ... +# EOF +# +# 2. Run from dev-browser directory with bundled Node: +# cd {{SKILLS_PATH}}/dev-browser && PATH="\${NODE_BIN_PATH}:\$PATH" npx tsx /tmp/accomplish-\${ACCOMPLISH_TASK_ID:-default}.mts +# +# WRONG (will fail - .ts files in /tmp default to CJS mode): +# cat > /tmp/script.ts <<'EOF' +# import { connect } from "@/client.js"; # Top-level await won't work! +# EOF +# +# ALWAYS use .mts extension for temp scripts! +############################################################################## + + + +The dev-browser server is automatically started when you begin a task. Before your first browser script, verify it's ready: + +\`\`\`bash +curl -s http://localhost:9224 +\`\`\` + +If it returns JSON with a \`wsEndpoint\`, proceed with browser automation. If connection is refused, the server is still starting - wait 2-3 seconds and check again. + +**Fallback** (only if server isn't running after multiple checks): +\`\`\`bash +cd {{SKILLS_PATH}}/dev-browser && PATH="\${NODE_BIN_PATH}:\$PATH" ./server.sh & +\`\`\` + + + +Write scripts to /tmp with .mts extension, then execute from dev-browser directory: + + +\`\`\`bash +cat > /tmp/accomplish-\${ACCOMPLISH_TASK_ID:-default}.mts <<'EOF' +import { connect, waitForPageLoad } from "@/client.js"; + +const taskId = process.env.ACCOMPLISH_TASK_ID || 'default'; +const client = await connect(); +const page = await client.page(\`\${taskId}-main\`); + +await page.goto("https://example.com"); +await waitForPageLoad(page); + +console.log({ title: await page.title(), url: page.url() }); +await client.disconnect(); +EOF +cd {{SKILLS_PATH}}/dev-browser && PATH="\${NODE_BIN_PATH}:\$PATH" npx tsx /tmp/accomplish-\${ACCOMPLISH_TASK_ID:-default}.mts +\`\`\` + + + + +1. **Small scripts**: Each script does ONE thing (navigate, click, fill, check) +2. **Evaluate state**: Log/return state at the end to decide next steps +3. **Task-scoped page names**: ALWAYS prefix page names with the task ID from environment: + \`\`\`typescript + const taskId = process.env.ACCOMPLISH_TASK_ID || 'default'; + const page = await client.page(\`\${taskId}-main\`); + \`\`\` + This ensures parallel tasks don't interfere with each other's browser pages. +4. **Task-scoped screenshot filenames**: ALWAYS prefix screenshot filenames with taskId to prevent parallel tasks from overwriting each other's screenshots: + \`\`\`typescript + await page.screenshot({ path: \`tmp/\${taskId}-screenshot.png\` }); + \`\`\` +5. **Disconnect to exit**: \`await client.disconnect()\` - pages persist on server +6. **Plain JS in evaluate**: \`page.evaluate()\` runs in browser - no TypeScript syntax + + + +\`\`\`typescript +const taskId = process.env.ACCOMPLISH_TASK_ID || 'default'; +const client = await connect(); + +const page = await client.page(\`\${taskId}-main\`); // Get or create named page +const pages = await client.list(); // List all page names +await client.close(\`\${taskId}-main\`); // Close a page +await client.disconnect(); // Disconnect (pages persist) + +// ARIA Snapshot methods +const snapshot = await client.getAISnapshot(\`\${taskId}-main\`); // Get accessibility tree +const element = await client.selectSnapshotRef(\`\${taskId}-main\`, "e5"); // Get element by ref +\`\`\` + +The \`page\` object is a standard Playwright Page. + + + +IMPORTANT: Always prefix screenshot filenames with taskId to avoid collisions with parallel tasks: +\`\`\`typescript +const taskId = process.env.ACCOMPLISH_TASK_ID || 'default'; +await page.screenshot({ path: \`tmp/\${taskId}-screenshot.png\` }); +await page.screenshot({ path: \`tmp/\${taskId}-full.png\`, fullPage: true }); +\`\`\` + + + +Use \`getAISnapshot()\` to discover page elements. Returns YAML-formatted accessibility tree with refs like \`[ref=e1]\`. Then use \`selectSnapshotRef()\` to interact: + + +\`\`\`typescript +const taskId = process.env.ACCOMPLISH_TASK_ID || 'default'; +const snapshot = await client.getAISnapshot(\`\${taskId}-main\`); +console.log(snapshot); // Find the ref you need + +const element = await client.selectSnapshotRef(\`\${taskId}-main\`, "e2"); +await element.click(); +\`\`\` + + + + +Page state persists after failures. Debug by reconnecting and taking a screenshot: + + +\`\`\`bash +cat > /tmp/accomplish-\${ACCOMPLISH_TASK_ID:-default}.mts <<'EOF' +import { connect } from "@/client.js"; + +const taskId = process.env.ACCOMPLISH_TASK_ID || 'default'; +const client = await connect(); +const page = await client.page(\`\${taskId}-main\`); + +await page.screenshot({ path: \`tmp/\${taskId}-debug.png\` }); +console.log({ url: page.url(), title: await page.title() }); + +await client.disconnect(); +EOF +cd {{SKILLS_PATH}}/dev-browser && PATH="\${NODE_BIN_PATH}:\$PATH" npx tsx /tmp/accomplish-\${ACCOMPLISH_TASK_ID:-default}.mts +\`\`\` + + + + +When you encounter a login page (e.g., Google Sign-In, OAuth screens, authentication prompts): +- Do NOT say you cannot log in or suggest alternative approaches +- Instead, ASK the user to log in manually in the browser window +- Take a screenshot to show the user the login page +- Say something like: "I've reached a login page. Please log in to your account in the browser window, then let me know when you're done." +- WAIT for the user to confirm they've logged in before continuing +- After the user confirms login, take another screenshot to verify you're past the login screen +- Then continue with the original task + +This interactive login flow is essential because: +- Users expect to authenticate themselves for security +- Many services require human verification (CAPTCHAs, 2FA) +- The agent should not give up on tasks that require authentication + + + +For saving/downloading content: +- Use browser's native download (click download buttons, Save As) +- Chrome handles downloads with its own permissions +- For text/data, copy to clipboard so users can paste where they want + + + + +CRITICAL: The user CANNOT see your text output or CLI prompts! +To ask ANY question or get user input, you MUST use the AskUserQuestion MCP tool. +See the ask-user-question skill for full documentation and examples. + + + + +- Use AskUserQuestion tool for clarifying questions before starting ambiguous tasks +- Write small, focused scripts - each does ONE thing +- After each script, evaluate the output before deciding next steps +- Be concise - don't narrate every internal action +- Hide implementation details - describe actions in user terms +- For multi-step tasks, summarize at the end rather than narrating each step +- Don't explain what bash commands you're running - just run them silently +- Don't announce server checks or startup - proceed directly to the task +- Only speak to the user when you have meaningful results or need input + +`; + +interface AgentConfig { + description?: string; + prompt?: string; + mode?: 'primary' | 'subagent' | 'all'; +} + +interface McpServerConfig { + type?: 'local' | 'remote'; + command?: string[]; + url?: string; + enabled?: boolean; + environment?: Record; + timeout?: number; +} + +interface OllamaProviderModelConfig { + name: string; + tools?: boolean; +} + +interface OllamaProviderConfig { + npm: string; + name: string; + options: { + baseURL: string; + }; + models: Record; +} + +interface BedrockProviderConfig { + options: { + region: string; + profile?: string; + }; +} + +interface OpenRouterProviderModelConfig { + name: string; + tools?: boolean; +} + +interface OpenRouterProviderConfig { + npm: string; + name: string; + options: { + baseURL: string; + }; + models: Record; +} + +interface LiteLLMProviderModelConfig { + name: string; + tools?: boolean; +} + +interface LiteLLMProviderConfig { + npm: string; + name: string; + options: { + baseURL: string; + apiKey?: string; + }; + models: Record; +} + +interface ZaiProviderModelConfig { + name: string; + tools?: boolean; +} + +interface ZaiProviderConfig { + npm: string; + name: string; + options: { + baseURL: string; + }; + models: Record; +} + +type ProviderConfig = OllamaProviderConfig | BedrockProviderConfig | OpenRouterProviderConfig | LiteLLMProviderConfig | ZaiProviderConfig; + +interface OpenCodeConfig { + $schema?: string; + model?: string; + default_agent?: string; + enabled_providers?: string[]; + permission?: string | Record>; + agent?: Record; + mcp?: Record; + provider?: Record; +} + +/** + * Generate OpenCode configuration file + * OpenCode reads config from .opencode.json in the working directory or + * from ~/.config/opencode/opencode.json + */ +export async function generateOpenCodeConfig(systemPromptAppend?: string): Promise { + const configDir = path.join(app.getPath('userData'), 'opencode'); + const configPath = path.join(configDir, 'opencode.json'); + + // Ensure directory exists + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + // Get skills directory path and inject into system prompt + const skillsPath = getSkillsPath(); + const baseSystemPrompt = ACCOMPLISH_SYSTEM_PROMPT_TEMPLATE.replace(/\{\{SKILLS_PATH\}\}/g, skillsPath); + const systemPrompt = systemPromptAppend + ? `${baseSystemPrompt}\n\n${systemPromptAppend}` + : baseSystemPrompt; + + // Get OpenCode config directory (parent of skills/) for OPENCODE_CONFIG_DIR + const openCodeConfigDir = getOpenCodeConfigDir(); + + console.log('[OpenCode Config] Skills path:', skillsPath); + console.log('[OpenCode Config] OpenCode config dir:', openCodeConfigDir); + + // Build file-permission MCP server command + const filePermissionServerPath = path.join(skillsPath, 'file-permission', 'src', 'index.ts'); + + // Enable providers - add ollama and litellm if configured + const ollamaConfig = getOllamaConfig(); + const litellmConfig = getLiteLLMConfig(); + const baseProviders = ['anthropic', 'openai', 'openrouter', 'google', 'xai', 'deepseek', 'zai-coding-plan', 'amazon-bedrock']; + let enabledProviders = [...baseProviders]; + if (ollamaConfig?.enabled) { + enabledProviders.push('ollama'); + } + if (litellmConfig?.enabled) { + enabledProviders.push('litellm'); + } + + // Build provider configurations + const providerConfig: Record = {}; + + // Add Ollama provider configuration if enabled + if (ollamaConfig?.enabled && ollamaConfig.models && ollamaConfig.models.length > 0) { + const ollamaModels: Record = {}; + for (const model of ollamaConfig.models) { + ollamaModels[model.id] = { + name: model.displayName, + tools: true, // Enable tool calling for all models + }; + } + + providerConfig.ollama = { + npm: '@ai-sdk/openai-compatible', + name: 'Ollama (local)', + options: { + baseURL: `${ollamaConfig.baseUrl}/v1`, // OpenAI-compatible endpoint + }, + models: ollamaModels, + }; + + console.log('[OpenCode Config] Ollama provider configured with models:', Object.keys(ollamaModels)); + } + + // Add OpenRouter provider configuration if API key is set + const openrouterKey = getApiKey('openrouter'); + if (openrouterKey) { + // Get the selected model to configure OpenRouter + const { getSelectedModel } = await import('../store/appSettings'); + const selectedModel = getSelectedModel(); + + const openrouterModels: Record = {}; + + // If a model is selected via OpenRouter, add it to the config + if (selectedModel?.provider === 'openrouter' && selectedModel.model) { + // Extract model ID from full ID (e.g., "openrouter/anthropic/claude-3.5-sonnet" -> "anthropic/claude-3.5-sonnet") + const modelId = selectedModel.model.replace('openrouter/', ''); + openrouterModels[modelId] = { + name: modelId, + tools: true, + }; + } + + // Only configure OpenRouter if we have at least one model + if (Object.keys(openrouterModels).length > 0) { + providerConfig.openrouter = { + npm: '@ai-sdk/openai-compatible', + name: 'OpenRouter', + options: { + baseURL: 'https://openrouter.ai/api/v1', + }, + models: openrouterModels, + }; + console.log('[OpenCode Config] OpenRouter provider configured with model:', Object.keys(openrouterModels)); + } + } + + // Add Bedrock provider configuration if credentials are stored + const bedrockCredsJson = getApiKey('bedrock'); + if (bedrockCredsJson) { + try { + const creds = JSON.parse(bedrockCredsJson) as BedrockCredentials; + + const bedrockOptions: BedrockProviderConfig['options'] = { + region: creds.region || 'us-east-1', + }; + + // Only add profile if using profile mode + if (creds.authType === 'profile' && creds.profileName) { + bedrockOptions.profile = creds.profileName; + } + + providerConfig['amazon-bedrock'] = { + options: bedrockOptions, + }; + + console.log('[OpenCode Config] Bedrock provider configured:', bedrockOptions); + } catch (e) { + console.warn('[OpenCode Config] Failed to parse Bedrock credentials:', e); + } + } + + // Add LiteLLM provider configuration if enabled + if (litellmConfig?.enabled && litellmConfig.baseUrl) { + // Get the selected model to configure LiteLLM + const { getSelectedModel } = await import('../store/appSettings'); + const selectedModel = getSelectedModel(); + + const litellmModels: Record = {}; + + // If a model is selected via LiteLLM, add it to the config + if (selectedModel?.provider === 'litellm' && selectedModel.model) { + // Extract model ID from full ID (e.g., "litellm/openai/gpt-4" -> "openai/gpt-4") + const modelId = selectedModel.model.replace('litellm/', ''); + litellmModels[modelId] = { + name: modelId, + tools: true, + }; + } + + // Only configure LiteLLM if we have at least one model + if (Object.keys(litellmModels).length > 0) { + // Get LiteLLM API key if configured + const litellmApiKey = getApiKey('litellm'); + + const litellmOptions: LiteLLMProviderConfig['options'] = { + baseURL: `${litellmConfig.baseUrl}/v1`, + }; + + // Add API key to options if available + if (litellmApiKey) { + litellmOptions.apiKey = litellmApiKey; + console.log('[OpenCode Config] LiteLLM API key configured'); + } + + providerConfig.litellm = { + npm: '@ai-sdk/openai-compatible', + name: 'LiteLLM', + options: litellmOptions, + models: litellmModels, + }; + console.log('[OpenCode Config] LiteLLM provider configured with model:', Object.keys(litellmModels)); + } + } + + // Add Z.AI Coding Plan provider configuration with all supported models + // This is needed because OpenCode's built-in zai-coding-plan provider may not have all models + const zaiKey = getApiKey('zai'); + if (zaiKey) { + const zaiModels: Record = { + 'glm-4.7-flashx': { name: 'GLM-4.7 FlashX (Latest)', tools: true }, + 'glm-4.7': { name: 'GLM-4.7', tools: true }, + 'glm-4.7-flash': { name: 'GLM-4.7 Flash', tools: true }, + 'glm-4.6': { name: 'GLM-4.6', tools: true }, + 'glm-4.5-flash': { name: 'GLM-4.5 Flash', tools: true }, + }; + + providerConfig['zai-coding-plan'] = { + npm: '@ai-sdk/openai-compatible', + name: 'Z.AI Coding Plan', + options: { + baseURL: 'https://open.bigmodel.cn/api/paas/v4', + }, + models: zaiModels, + }; + console.log('[OpenCode Config] Z.AI Coding Plan provider configured with models:', Object.keys(zaiModels)); + } + + const config: OpenCodeConfig = { + $schema: 'https://opencode.ai/config.json', + default_agent: ACCOMPLISH_AGENT_NAME, + // Enable all supported providers - providers auto-configure when API keys are set via env vars + enabled_providers: enabledProviders, + // Auto-allow all tool permissions - the system prompt instructs the agent to use + // AskUserQuestion for user confirmations, which shows in the UI as an interactive modal. + // CLI-level permission prompts don't show in the UI and would block task execution. + permission: 'allow', + provider: Object.keys(providerConfig).length > 0 ? providerConfig : undefined, + agent: { + [ACCOMPLISH_AGENT_NAME]: { + description: 'Browser automation assistant using dev-browser', + prompt: systemPrompt, + mode: 'primary', + }, + }, + // MCP servers for additional tools + mcp: { + 'file-permission': { + type: 'local', + command: ['npx', 'tsx', filePermissionServerPath], + enabled: true, + environment: { + PERMISSION_API_PORT: String(PERMISSION_API_PORT), + }, + timeout: 10000, + }, + 'ask-user-question': { + type: 'local', + command: ['npx', 'tsx', path.join(skillsPath, 'ask-user-question', 'src', 'index.ts')], + enabled: true, + environment: { + QUESTION_API_PORT: String(QUESTION_API_PORT), + }, + timeout: 10000, + }, + }, + }; + + // Write config file + const configJson = JSON.stringify(config, null, 2); + fs.writeFileSync(configPath, configJson); + + // Set environment variables for OpenCode to find the config and skills + process.env.OPENCODE_CONFIG = configPath; + process.env.OPENCODE_CONFIG_DIR = openCodeConfigDir; + + console.log('[OpenCode Config] Generated config at:', configPath); + console.log('[OpenCode Config] Full config:', configJson); + console.log('[OpenCode Config] OPENCODE_CONFIG env set to:', process.env.OPENCODE_CONFIG); + console.log('[OpenCode Config] OPENCODE_CONFIG_DIR env set to:', process.env.OPENCODE_CONFIG_DIR); + + return configPath; +} + +/** + * Get the path where OpenCode config is stored + */ +export function getOpenCodeConfigPath(): string { + return path.join(app.getPath('userData'), 'opencode', 'opencode.json'); +} + +/** + * Get the path to OpenCode CLI's auth.json + * OpenCode stores credentials in ~/.local/share/opencode/auth.json + */ +export function getOpenCodeAuthPath(): string { + const homeDir = app.getPath('home'); + if (process.platform === 'win32') { + return path.join(homeDir, 'AppData', 'Local', 'opencode', 'auth.json'); + } + return path.join(homeDir, '.local', 'share', 'opencode', 'auth.json'); +} + +/** + * Sync API keys from Openwork's secure storage to OpenCode CLI's auth.json + * This allows OpenCode CLI to recognize DeepSeek and Z.AI providers + */ +export async function syncApiKeysToOpenCodeAuth(): Promise { + const { getAllApiKeys } = await import('../store/secureStorage'); + const apiKeys = await getAllApiKeys(); + + const authPath = getOpenCodeAuthPath(); + const authDir = path.dirname(authPath); + + // Ensure directory exists + if (!fs.existsSync(authDir)) { + fs.mkdirSync(authDir, { recursive: true }); + } + + // Read existing auth.json or create empty object + let auth: Record = {}; + if (fs.existsSync(authPath)) { + try { + auth = JSON.parse(fs.readFileSync(authPath, 'utf-8')); + } catch (e) { + console.warn('[OpenCode Auth] Failed to parse existing auth.json, creating new one'); + auth = {}; + } + } + + let updated = false; + + // Sync DeepSeek API key + if (apiKeys.deepseek) { + if (!auth['deepseek'] || auth['deepseek'].key !== apiKeys.deepseek) { + auth['deepseek'] = { type: 'api', key: apiKeys.deepseek }; + updated = true; + console.log('[OpenCode Auth] Synced DeepSeek API key'); + } + } + + // Sync Z.AI Coding Plan API key (maps to 'zai-coding-plan' provider in OpenCode CLI) + if (apiKeys.zai) { + if (!auth['zai-coding-plan'] || auth['zai-coding-plan'].key !== apiKeys.zai) { + auth['zai-coding-plan'] = { type: 'api', key: apiKeys.zai }; + updated = true; + console.log('[OpenCode Auth] Synced Z.AI Coding Plan API key'); + } + } + + // Write updated auth.json + if (updated) { + fs.writeFileSync(authPath, JSON.stringify(auth, null, 2)); + console.log('[OpenCode Auth] Updated auth.json at:', authPath); + } +} diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts b/openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts new file mode 100644 index 000000000..5b3c8b8dc --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts @@ -0,0 +1,145 @@ +import { EventEmitter } from 'events'; +import type { OpenCodeMessage } from '@accomplish/shared'; + +export interface StreamParserEvents { + message: [OpenCodeMessage]; + error: [Error]; +} + +// Maximum buffer size to prevent memory exhaustion (10MB) +const MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +/** + * Parses NDJSON (newline-delimited JSON) stream from OpenCode CLI + */ +export class StreamParser extends EventEmitter { + private buffer: string = ''; + + /** + * Feed raw data from stdout + */ + feed(chunk: string): void { + this.buffer += chunk; + + // Prevent memory exhaustion from unbounded buffer growth + if (this.buffer.length > MAX_BUFFER_SIZE) { + this.emit('error', new Error('Stream buffer size exceeded maximum limit')); + // Keep the last portion of the buffer to maintain parsing continuity + this.buffer = this.buffer.slice(-MAX_BUFFER_SIZE / 2); + } + + this.parseBuffer(); + } + + /** + * Parse complete lines from the buffer + */ + private parseBuffer(): void { + const lines = this.buffer.split('\n'); + + // Keep incomplete line in buffer + this.buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + this.parseLine(line); + } + } + } + + /** + * Check if a line is terminal UI decoration (not JSON) + * These are outputted by the CLI's interactive prompts + */ + private isTerminalDecoration(line: string): boolean { + const trimmed = line.trim(); + // Box-drawing and UI characters used by the CLI's interactive prompts + const terminalChars = ['│', '┌', '┐', '└', '┘', '├', '┤', '┬', '┴', '┼', '─', '◆', '●', '○', '◇']; + // Check if line starts with a terminal decoration character + if (terminalChars.some(char => trimmed.startsWith(char))) { + return true; + } + // Also skip ANSI escape sequences and other control characters + if (/^[\x00-\x1F\x7F]/.test(trimmed) || /^\x1b\[/.test(trimmed)) { + return true; + } + return false; + } + + /** + * Parse a single JSON line + */ + private parseLine(line: string): void { + const trimmed = line.trim(); + + // Skip empty lines + if (!trimmed) return; + + // Skip terminal UI decorations (interactive prompts, box-drawing chars) + if (this.isTerminalDecoration(trimmed)) { + return; + } + + // Only attempt to parse lines that look like JSON (start with {) + if (!trimmed.startsWith('{')) { + // Log non-JSON lines for debugging but don't emit errors + // These could be CLI status messages, etc. + console.log('[StreamParser] Skipping non-JSON line:', trimmed.substring(0, 50)); + return; + } + + try { + const message = JSON.parse(trimmed) as OpenCodeMessage; + + // Log parsed message for debugging + console.log('[StreamParser] Parsed message type:', message.type); + + // Enhanced logging for MCP/Playwriter-related messages + if (message.type === 'tool_call' || message.type === 'tool_result') { + const part = message.part as Record; + console.log('[StreamParser] Tool message details:', { + type: message.type, + tool: part?.tool, + hasInput: !!part?.input, + hasOutput: !!part?.output, + }); + + // Check if it's a dev-browser tool + const toolName = String(part?.tool || '').toLowerCase(); + const output = String(part?.output || '').toLowerCase(); + if (toolName.includes('dev-browser') || + toolName.includes('browser') || + toolName.includes('mcp') || + output.includes('dev-browser') || + output.includes('browser')) { + console.log('[StreamParser] >>> DEV-BROWSER MESSAGE <<<'); + console.log('[StreamParser] Full message:', JSON.stringify(message, null, 2)); + } + } + + this.emit('message', message); + } catch (err) { + // Log parse error but continue processing - this shouldn't happen often + // since we already check for { prefix + console.error('[StreamParser] Failed to parse JSON line:', trimmed.substring(0, 100), err); + this.emit('error', new Error(`Failed to parse JSON: ${trimmed.substring(0, 50)}...`)); + } + } + + /** + * Flush any remaining buffer content + */ + flush(): void { + if (this.buffer.trim()) { + this.parseLine(this.buffer); + this.buffer = ''; + } + } + + /** + * Reset the parser + */ + reset(): void { + this.buffer = ''; + } +} diff --git a/openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts b/openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts new file mode 100644 index 000000000..5b3d8b34d --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts @@ -0,0 +1,650 @@ +/** + * TaskManager - Manages multiple concurrent OpenCode CLI task executions + * + * This class implements a process manager pattern to support true parallel + * session execution. Each task gets its own OpenCodeAdapter instance with + * isolated PTY process, state, and event handling. + */ + +import { OpenCodeAdapter, isOpenCodeCliInstalled, OpenCodeCliNotFoundError } from './adapter'; +import { getSkillsPath } from './config-generator'; +import { getNpxPath, getBundledNodePaths } from '../utils/bundled-node'; +import { spawn } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import type { + TaskConfig, + Task, + TaskResult, + TaskStatus, + OpenCodeMessage, + PermissionRequest, +} from '@accomplish/shared'; + +/** + * Check if system Chrome is installed + */ +function isSystemChromeInstalled(): boolean { + if (process.platform === 'darwin') { + return fs.existsSync('/Applications/Google Chrome.app'); + } else if (process.platform === 'win32') { + // Check common Windows Chrome locations + const programFiles = process.env['PROGRAMFILES'] || 'C:\\Program Files'; + const programFilesX86 = process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)'; + return ( + fs.existsSync(path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe')) || + fs.existsSync(path.join(programFilesX86, 'Google', 'Chrome', 'Application', 'chrome.exe')) + ); + } + // Linux - check common paths + return fs.existsSync('/usr/bin/google-chrome') || fs.existsSync('/usr/bin/chromium-browser'); +} + +/** + * Check if Playwright Chromium is installed + */ +function isPlaywrightInstalled(): boolean { + const homeDir = os.homedir(); + const possiblePaths = [ + path.join(homeDir, 'Library', 'Caches', 'ms-playwright'), // macOS + path.join(homeDir, '.cache', 'ms-playwright'), // Linux + ]; + + if (process.platform === 'win32' && process.env.LOCALAPPDATA) { + possiblePaths.unshift(path.join(process.env.LOCALAPPDATA, 'ms-playwright')); + } + + for (const playwrightDir of possiblePaths) { + if (fs.existsSync(playwrightDir)) { + try { + const entries = fs.readdirSync(playwrightDir); + if (entries.some((entry) => entry.startsWith('chromium'))) { + return true; + } + } catch { + continue; + } + } + } + return false; +} + +/** + * Install Playwright Chromium browser. + * Returns a promise that resolves when installation is complete. + * Uses bundled Node.js to ensure it works in packaged app. + */ +async function installPlaywrightChromium( + onProgress?: (message: string) => void +): Promise { + return new Promise((resolve, reject) => { + const skillsPath = getSkillsPath(); + const devBrowserDir = path.join(skillsPath, 'dev-browser'); + + // Use bundled npx for packaged app compatibility + const npxPath = getNpxPath(); + const bundledPaths = getBundledNodePaths(); + + console.log(`[TaskManager] Installing Playwright Chromium using bundled npx: ${npxPath}`); + onProgress?.('Downloading browser...'); + + // Build environment with bundled node in PATH + let spawnEnv: NodeJS.ProcessEnv = { ...process.env }; + if (bundledPaths) { + const delimiter = process.platform === 'win32' ? ';' : ':'; + spawnEnv.PATH = `${bundledPaths.binDir}${delimiter}${process.env.PATH || ''}`; + } + + const child = spawn(npxPath, ['playwright', 'install', 'chromium'], { + cwd: devBrowserDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: spawnEnv, + }); + + child.stdout?.on('data', (data: Buffer) => { + const line = data.toString().trim(); + if (line) { + console.log(`[Playwright Install] ${line}`); + // Send progress info: percentage updates and "Downloading X" messages + if (line.includes('%') || line.toLowerCase().startsWith('downloading')) { + onProgress?.(line); + } + } + }); + + child.stderr?.on('data', (data: Buffer) => { + const line = data.toString().trim(); + if (line) { + console.log(`[Playwright Install] ${line}`); + } + }); + + child.on('close', (code) => { + if (code === 0) { + console.log('[TaskManager] Playwright Chromium installed successfully'); + onProgress?.('Browser installed successfully!'); + resolve(); + } else { + reject(new Error(`Playwright install failed with code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); +} + +/** + * Ensure the dev-browser server is running. + * Called before starting tasks to pre-warm the browser. + * + * If neither system Chrome nor Playwright is installed, downloads Playwright first. + * + * Note: We don't check if server is already running via fetch() because + * that triggers macOS "Local Network" permission dialog. Instead, we just + * spawn server.sh which handles the "already running" case internally. + */ +async function ensureDevBrowserServer( + onProgress?: (progress: { stage: string; message?: string }) => void +): Promise { + // Check if we have a browser available + const hasChrome = isSystemChromeInstalled(); + const hasPlaywright = isPlaywrightInstalled(); + + console.log(`[TaskManager] Browser check: Chrome=${hasChrome}, Playwright=${hasPlaywright}`); + + // If no browser available, install Playwright first + if (!hasChrome && !hasPlaywright) { + console.log('[TaskManager] No browser available, installing Playwright Chromium...'); + onProgress?.({ + stage: 'setup', + message: 'Chrome not found. Downloading browser (one-time setup, ~2 min)...', + }); + + try { + await installPlaywrightChromium((msg) => { + onProgress?.({ stage: 'setup', message: msg }); + }); + } catch (error) { + console.error('[TaskManager] Failed to install Playwright:', error); + // Don't throw - let agent handle the failure + } + } + + // Now start the server + try { + const skillsPath = getSkillsPath(); + const serverScript = path.join(skillsPath, 'dev-browser', 'server.sh'); + + // Build environment with bundled Node.js in PATH + const bundledPaths = getBundledNodePaths(); + let spawnEnv: NodeJS.ProcessEnv = { ...process.env }; + if (bundledPaths) { + const delimiter = process.platform === 'win32' ? ';' : ':'; + spawnEnv.PATH = `${bundledPaths.binDir}${delimiter}${process.env.PATH || ''}`; + spawnEnv.NODE_BIN_PATH = bundledPaths.binDir; + } + + // Spawn server in background (detached, unref to not block) + const child = spawn('bash', [serverScript], { + detached: true, + stdio: 'ignore', + cwd: path.join(skillsPath, 'dev-browser'), + env: spawnEnv, + }); + child.unref(); + + console.log('[TaskManager] Dev-browser server spawn initiated'); + } catch (error) { + console.error('[TaskManager] Failed to start dev-browser server:', error); + } +} + +/** + * Callbacks for task events - scoped to a specific task + */ +export interface TaskCallbacks { + onMessage: (message: OpenCodeMessage) => void; + onProgress: (progress: { stage: string; message?: string }) => void; + onPermissionRequest: (request: PermissionRequest) => void; + onComplete: (result: TaskResult) => void; + onError: (error: Error) => void; + onStatusChange?: (status: TaskStatus) => void; + onDebug?: (log: { type: string; message: string; data?: unknown }) => void; +} + +/** + * Internal representation of a managed task + */ +interface ManagedTask { + taskId: string; + adapter: OpenCodeAdapter; + callbacks: TaskCallbacks; + cleanup: () => void; + createdAt: Date; +} + +/** + * Queued task waiting for execution + */ +interface QueuedTask { + taskId: string; + config: TaskConfig; + callbacks: TaskCallbacks; + createdAt: Date; +} + +/** + * Default maximum number of concurrent tasks + * Can be configured via constructor + */ +const DEFAULT_MAX_CONCURRENT_TASKS = 10; + +/** + * TaskManager manages OpenCode CLI task executions with parallel execution + * + * Multiple tasks can run concurrently up to maxConcurrentTasks. + * Each task gets its own isolated PTY process and browser pages (prefixed with task ID). + */ +export class TaskManager { + private activeTasks: Map = new Map(); + private taskQueue: QueuedTask[] = []; + private maxConcurrentTasks: number; + + constructor(options?: { maxConcurrentTasks?: number }) { + this.maxConcurrentTasks = options?.maxConcurrentTasks ?? DEFAULT_MAX_CONCURRENT_TASKS; + } + + /** + * Start a new task. Multiple tasks can run in parallel up to maxConcurrentTasks. + * If at capacity, new tasks are queued and start automatically when a task completes. + */ + async startTask( + taskId: string, + config: TaskConfig, + callbacks: TaskCallbacks + ): Promise { + // Check if CLI is installed + const cliInstalled = await isOpenCodeCliInstalled(); + if (!cliInstalled) { + throw new OpenCodeCliNotFoundError(); + } + + // Check if task already exists (either running or queued) + if (this.activeTasks.has(taskId) || this.taskQueue.some(q => q.taskId === taskId)) { + throw new Error(`Task ${taskId} is already running or queued`); + } + + // If at max concurrent tasks, queue this one + if (this.activeTasks.size >= this.maxConcurrentTasks) { + console.log(`[TaskManager] At max concurrent tasks (${this.maxConcurrentTasks}). Queueing task ${taskId}`); + return this.queueTask(taskId, config, callbacks); + } + + // Execute immediately (parallel execution) + return this.executeTask(taskId, config, callbacks); + } + + /** + * Queue a task for later execution + */ + private queueTask( + taskId: string, + config: TaskConfig, + callbacks: TaskCallbacks + ): Task { + // Check queue limit (allow same number of queued tasks as max concurrent) + if (this.taskQueue.length >= this.maxConcurrentTasks) { + throw new Error( + `Maximum queued tasks (${this.maxConcurrentTasks}) reached. Please wait for tasks to complete.` + ); + } + + const queuedTask: QueuedTask = { + taskId, + config, + callbacks, + createdAt: new Date(), + }; + + this.taskQueue.push(queuedTask); + console.log(`[TaskManager] Task ${taskId} queued. Queue length: ${this.taskQueue.length}`); + + // Return a task object with 'queued' status + return { + id: taskId, + prompt: config.prompt, + status: 'queued', + messages: [], + createdAt: new Date().toISOString(), + }; + } + + /** + * Execute a task immediately (internal) + */ + private async executeTask( + taskId: string, + config: TaskConfig, + callbacks: TaskCallbacks + ): Promise { + // Create a new adapter instance for this task + const adapter = new OpenCodeAdapter(taskId); + + // Wire up event listeners + const onMessage = (message: OpenCodeMessage) => { + callbacks.onMessage(message); + }; + + const onProgress = (progress: { stage: string; message?: string }) => { + callbacks.onProgress(progress); + }; + + const onPermissionRequest = (request: PermissionRequest) => { + callbacks.onPermissionRequest(request); + }; + + const onComplete = (result: TaskResult) => { + callbacks.onComplete(result); + // Auto-cleanup on completion and process queue + this.cleanupTask(taskId); + this.processQueue(); + }; + + const onError = (error: Error) => { + callbacks.onError(error); + // Auto-cleanup on error and process queue + this.cleanupTask(taskId); + this.processQueue(); + }; + + const onDebug = (log: { type: string; message: string; data?: unknown }) => { + callbacks.onDebug?.(log); + }; + + // Attach listeners + adapter.on('message', onMessage); + adapter.on('progress', onProgress); + adapter.on('permission-request', onPermissionRequest); + adapter.on('complete', onComplete); + adapter.on('error', onError); + adapter.on('debug', onDebug); + + // Create cleanup function + const cleanup = () => { + adapter.off('message', onMessage); + adapter.off('progress', onProgress); + adapter.off('permission-request', onPermissionRequest); + adapter.off('complete', onComplete); + adapter.off('error', onError); + adapter.off('debug', onDebug); + adapter.dispose(); + }; + + // Register the managed task + const managedTask: ManagedTask = { + taskId, + adapter, + callbacks, + cleanup, + createdAt: new Date(), + }; + this.activeTasks.set(taskId, managedTask); + + console.log(`[TaskManager] Executing task ${taskId}. Active tasks: ${this.activeTasks.size}`); + + // Create task object immediately so UI can navigate + const task: Task = { + id: taskId, + prompt: config.prompt, + status: 'running', + messages: [], + createdAt: new Date().toISOString(), + }; + + // Start browser setup and agent asynchronously + // This allows the UI to navigate immediately while setup happens + (async () => { + try { + // Ensure browser is available (may download Playwright if needed) + await ensureDevBrowserServer(callbacks.onProgress); + + // Now start the agent + await adapter.startTask({ ...config, taskId }); + } catch (error) { + // Cleanup on failure and process queue + callbacks.onError(error instanceof Error ? error : new Error(String(error))); + this.cleanupTask(taskId); + this.processQueue(); + } + })(); + + return task; + } + + /** + * Process the queue - start queued tasks if we have capacity + */ + private async processQueue(): Promise { + // Start queued tasks while we have capacity + while (this.taskQueue.length > 0 && this.activeTasks.size < this.maxConcurrentTasks) { + const nextTask = this.taskQueue.shift()!; + console.log(`[TaskManager] Processing queue. Starting task ${nextTask.taskId}. Active: ${this.activeTasks.size}, Remaining in queue: ${this.taskQueue.length}`); + + // Notify that task is now running + nextTask.callbacks.onStatusChange?.('running'); + + try { + await this.executeTask(nextTask.taskId, nextTask.config, nextTask.callbacks); + } catch (error) { + console.error(`[TaskManager] Error starting queued task ${nextTask.taskId}:`, error); + nextTask.callbacks.onError(error instanceof Error ? error : new Error(String(error))); + } + } + + if (this.taskQueue.length === 0) { + console.log('[TaskManager] Queue empty, no more tasks to process'); + } + } + + /** + * Cancel a specific task (running or queued) + */ + async cancelTask(taskId: string): Promise { + // Check if it's a queued task + const queueIndex = this.taskQueue.findIndex(q => q.taskId === taskId); + if (queueIndex !== -1) { + console.log(`[TaskManager] Cancelling queued task ${taskId}`); + this.taskQueue.splice(queueIndex, 1); + return; + } + + // Otherwise, it's a running task + const managedTask = this.activeTasks.get(taskId); + if (!managedTask) { + console.warn(`[TaskManager] Task ${taskId} not found for cancellation`); + return; + } + + console.log(`[TaskManager] Cancelling running task ${taskId}`); + + try { + await managedTask.adapter.cancelTask(); + } finally { + this.cleanupTask(taskId); + // Process queue after cancellation + this.processQueue(); + } + } + + /** + * Interrupt a running task (graceful Ctrl+C) + * Unlike cancel, this doesn't kill the process - it just interrupts the current operation + * and allows the agent to wait for the next user input. + */ + async interruptTask(taskId: string): Promise { + const managedTask = this.activeTasks.get(taskId); + if (!managedTask) { + console.warn(`[TaskManager] Task ${taskId} not found for interruption`); + return; + } + + console.log(`[TaskManager] Interrupting task ${taskId}`); + await managedTask.adapter.interruptTask(); + } + + /** + * Cancel a queued task and optionally revert to a previous status + * Used for cancelling follow-ups on completed tasks + */ + cancelQueuedTask(taskId: string): boolean { + const queueIndex = this.taskQueue.findIndex(q => q.taskId === taskId); + if (queueIndex === -1) { + return false; + } + + console.log(`[TaskManager] Removing task ${taskId} from queue`); + this.taskQueue.splice(queueIndex, 1); + return true; + } + + /** + * Check if there are any running tasks + */ + hasRunningTask(): boolean { + return this.activeTasks.size > 0; + } + + /** + * Check if a specific task is queued + */ + isTaskQueued(taskId: string): boolean { + return this.taskQueue.some(q => q.taskId === taskId); + } + + /** + * Get queue position (1-based) for a task, or 0 if not queued + */ + getQueuePosition(taskId: string): number { + const index = this.taskQueue.findIndex(q => q.taskId === taskId); + return index === -1 ? 0 : index + 1; + } + + /** + * Get the current queue length + */ + getQueueLength(): number { + return this.taskQueue.length; + } + + /** + * Send a response to a specific task's PTY (for permissions/questions) + */ + async sendResponse(taskId: string, response: string): Promise { + const managedTask = this.activeTasks.get(taskId); + if (!managedTask) { + throw new Error(`Task ${taskId} not found or not active`); + } + + await managedTask.adapter.sendResponse(response); + } + + /** + * Get the session ID for a specific task + */ + getSessionId(taskId: string): string | null { + const managedTask = this.activeTasks.get(taskId); + return managedTask?.adapter.getSessionId() ?? null; + } + + /** + * Check if a task is active + */ + hasActiveTask(taskId: string): boolean { + return this.activeTasks.has(taskId); + } + + /** + * Get the number of active tasks + */ + getActiveTaskCount(): number { + return this.activeTasks.size; + } + + /** + * Get all active task IDs + */ + getActiveTaskIds(): string[] { + return Array.from(this.activeTasks.keys()); + } + + /** + * Get the currently running task ID (not queued) + * Returns the first active task if multiple are running + */ + getActiveTaskId(): string | null { + const firstActive = this.activeTasks.keys().next(); + return firstActive.done ? null : firstActive.value; + } + + /** + * Cleanup a specific task (internal) + */ + private cleanupTask(taskId: string): void { + const managedTask = this.activeTasks.get(taskId); + if (managedTask) { + console.log(`[TaskManager] Cleaning up task ${taskId}`); + managedTask.cleanup(); + this.activeTasks.delete(taskId); + console.log(`[TaskManager] Task ${taskId} cleaned up. Active tasks: ${this.activeTasks.size}`); + } + } + + /** + * Dispose all tasks and cleanup resources + * Called on app quit + */ + dispose(): void { + console.log(`[TaskManager] Disposing all tasks (${this.activeTasks.size} active, ${this.taskQueue.length} queued)`); + + // Clear the queue + this.taskQueue = []; + + for (const [taskId, managedTask] of this.activeTasks) { + try { + managedTask.cleanup(); + } catch (error) { + console.error(`[TaskManager] Error cleaning up task ${taskId}:`, error); + } + } + + this.activeTasks.clear(); + console.log('[TaskManager] All tasks disposed'); + } +} + +// Singleton TaskManager instance for the application +let taskManagerInstance: TaskManager | null = null; + +/** + * Get the global TaskManager instance + */ +export function getTaskManager(): TaskManager { + if (!taskManagerInstance) { + taskManagerInstance = new TaskManager(); + } + return taskManagerInstance; +} + +/** + * Dispose the global TaskManager instance + * Called on app quit + */ +export function disposeTaskManager(): void { + if (taskManagerInstance) { + taskManagerInstance.dispose(); + taskManagerInstance = null; + } +} diff --git a/openwork-memos-integration/apps/desktop/src/main/permission-api.ts b/openwork-memos-integration/apps/desktop/src/main/permission-api.ts new file mode 100644 index 000000000..b6d135839 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/permission-api.ts @@ -0,0 +1,356 @@ +/** + * Permission API Server + * + * HTTP server that the file-permission MCP server calls to request + * user permission for file operations. This bridges the MCP server + * (separate process) with the Electron UI. + */ + +import http from 'http'; +import type { BrowserWindow } from 'electron'; +import type { PermissionRequest, FileOperation } from '@accomplish/shared'; + +export const PERMISSION_API_PORT = 9226; +export const QUESTION_API_PORT = 9227; + +interface PendingPermission { + resolve: (allowed: boolean) => void; + timeoutId: NodeJS.Timeout; +} + +interface PendingQuestion { + resolveWithData: (data: { selectedOptions?: string[]; customText?: string; denied?: boolean }) => void; + timeoutId: NodeJS.Timeout; +} + +// Store pending permission requests waiting for user response +const pendingPermissions = new Map(); + +// Store pending question requests waiting for user response +const pendingQuestions = new Map(); + +// Store reference to main window and task manager +let mainWindow: BrowserWindow | null = null; +let getActiveTaskId: (() => string | null) | null = null; + +/** + * Initialize the permission API with dependencies + */ +export function initPermissionApi( + window: BrowserWindow, + taskIdGetter: () => string | null +): void { + mainWindow = window; + getActiveTaskId = taskIdGetter; +} + +/** + * Resolve a pending permission request from the MCP server + * Called when user responds via the UI + */ +export function resolvePermission(requestId: string, allowed: boolean): boolean { + const pending = pendingPermissions.get(requestId); + if (!pending) { + return false; + } + + clearTimeout(pending.timeoutId); + pending.resolve(allowed); + pendingPermissions.delete(requestId); + return true; +} + +/** + * Resolve a pending question request from the MCP server + * Called when user responds via the UI + */ +export function resolveQuestion( + requestId: string, + response: { selectedOptions?: string[]; customText?: string; denied?: boolean } +): boolean { + const pending = pendingQuestions.get(requestId); + if (!pending) { + return false; + } + + clearTimeout(pending.timeoutId); + pending.resolveWithData(response); + pendingQuestions.delete(requestId); + return true; +} + +/** + * Generate a unique request ID for file permissions + */ +function generateRequestId(): string { + return `filereq_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; +} + +/** + * Generate a unique request ID for questions + */ +function generateQuestionRequestId(): string { + return `questionreq_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; +} + +/** + * Create and start the HTTP server for permission requests + */ +export function startPermissionApiServer(): http.Server { + const server = http.createServer(async (req, res) => { + // CORS headers for local requests + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + // Handle preflight + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + // Only handle POST /permission + if (req.method !== 'POST' || req.url !== '/permission') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + // Parse request body + let body = ''; + for await (const chunk of req) { + body += chunk; + } + + let data: { + operation?: string; + filePath?: string; + filePaths?: string[]; + targetPath?: string; + contentPreview?: string; + }; + + try { + data = JSON.parse(body); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + // Validate required fields + if (!data.operation || (!data.filePath && (!data.filePaths || data.filePaths.length === 0))) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'operation and either filePath or filePaths are required' })); + return; + } + + // Validate operation type + const validOperations = ['create', 'delete', 'rename', 'move', 'modify', 'overwrite']; + if (!validOperations.includes(data.operation)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Invalid operation. Must be one of: ${validOperations.join(', ')}` })); + return; + } + + // Check if we have the necessary dependencies + if (!mainWindow || mainWindow.isDestroyed() || !getActiveTaskId) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Permission API not initialized' })); + return; + } + + const taskId = getActiveTaskId(); + if (!taskId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'No active task' })); + return; + } + + const requestId = generateRequestId(); + + // Create permission request for the UI + const permissionRequest: PermissionRequest = { + id: requestId, + taskId, + type: 'file', + fileOperation: data.operation as FileOperation, + filePath: data.filePath, + filePaths: data.filePaths, + targetPath: data.targetPath, + contentPreview: data.contentPreview?.substring(0, 500), + createdAt: new Date().toISOString(), + }; + + // Send to renderer + mainWindow.webContents.send('permission:request', permissionRequest); + + // Wait for user response (with 5 minute timeout) + const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; + + try { + const allowed = await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pendingPermissions.delete(requestId); + reject(new Error('Permission request timed out')); + }, PERMISSION_TIMEOUT_MS); + + pendingPermissions.set(requestId, { resolve, timeoutId }); + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ allowed })); + } catch (error) { + res.writeHead(408, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request timed out', allowed: false })); + } + }); + + server.listen(PERMISSION_API_PORT, '127.0.0.1', () => { + console.log(`[Permission API] Server listening on port ${PERMISSION_API_PORT}`); + }); + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + console.warn(`[Permission API] Port ${PERMISSION_API_PORT} already in use, skipping server start`); + } else { + console.error('[Permission API] Server error:', error); + } + }); + + return server; +} + +/** + * Create and start the HTTP server for question requests + */ +export function startQuestionApiServer(): http.Server { + const server = http.createServer(async (req, res) => { + // CORS headers for local requests + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + // Handle preflight + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + // Only handle POST /question + if (req.method !== 'POST' || req.url !== '/question') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + // Parse request body + let body = ''; + for await (const chunk of req) { + body += chunk; + } + + let data: { + question?: string; + header?: string; + options?: Array<{ label: string; description?: string }>; + multiSelect?: boolean; + }; + + try { + data = JSON.parse(body); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + // Validate required fields + if (!data.question) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'question is required' })); + return; + } + + // Check if we have the necessary dependencies + if (!mainWindow || mainWindow.isDestroyed() || !getActiveTaskId) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Question API not initialized' })); + return; + } + + const taskId = getActiveTaskId(); + if (!taskId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'No active task' })); + return; + } + + const requestId = generateQuestionRequestId(); + + // Create question request for the UI + const questionRequest: PermissionRequest = { + id: requestId, + taskId, + type: 'question', + question: data.question, + header: data.header, + options: data.options, + multiSelect: data.multiSelect, + createdAt: new Date().toISOString(), + }; + + // Send to renderer + mainWindow.webContents.send('permission:request', questionRequest); + + // Wait for user response (with 5 minute timeout) + const QUESTION_TIMEOUT_MS = 5 * 60 * 1000; + + try { + const response = await new Promise<{ selectedOptions?: string[]; customText?: string; denied?: boolean }>((resolve, reject) => { + const timeoutId = setTimeout(() => { + pendingQuestions.delete(requestId); + reject(new Error('Question request timed out')); + }, QUESTION_TIMEOUT_MS); + + pendingQuestions.set(requestId, { resolveWithData: resolve, timeoutId }); + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + } catch (error) { + res.writeHead(408, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Request timed out', denied: true })); + } + }); + + server.listen(QUESTION_API_PORT, '127.0.0.1', () => { + console.log(`[Question API] Server listening on port ${QUESTION_API_PORT}`); + }); + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + console.warn(`[Question API] Port ${QUESTION_API_PORT} already in use, skipping server start`); + } else { + console.error('[Question API] Server error:', error); + } + }); + + return server; +} + +/** + * Check if a request ID is a file permission request from the MCP server + */ +export function isFilePermissionRequest(requestId: string): boolean { + return requestId.startsWith('filereq_'); +} + +/** + * Check if a request ID is a question request from the MCP server + */ +export function isQuestionRequest(requestId: string): boolean { + return requestId.startsWith('questionreq_'); +} diff --git a/openwork-memos-integration/apps/desktop/src/main/services/memory.ts b/openwork-memos-integration/apps/desktop/src/main/services/memory.ts new file mode 100644 index 000000000..02e6f75b1 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/services/memory.ts @@ -0,0 +1,329 @@ +import type { TaskMessage } from '@accomplish/shared'; +import { getMemoryUserId } from '../store/appSettings'; +import { getApiKey } from '../store/secureStorage'; + +const DEFAULT_BASE_URL = 'https://memos.memtensor.cn/api/openmem/v1'; +const DEFAULT_TOP_K = 5; +const DEFAULT_TIMEOUT_MS = 6000; +const DEFAULT_MAX_CONTEXT_LENGTH = 3000; +const DEFAULT_MAX_MESSAGE_COUNT = 8; +const DEFAULT_MAX_MESSAGE_LENGTH = 2000; + +interface MemoryMessage { + role: 'user' | 'assistant'; + content: string; +} + +interface MemoryConfig { + enabled: boolean; + baseUrl?: string; + apiKey?: string; + apiKeyHeader: string; + apiKeyScheme: string; + searchPath: string; + addPath: string; + timeoutMs: number; + topK: number; + maxContextLength: number; +} + +function getEnv(): Record { + const env = (globalThis as { process?: { env?: Record } }).process?.env; + return env ?? {}; +} + +function resolveMemoryConfig(): MemoryConfig { + const env = getEnv(); + const envBaseUrl = env.MEMOS_BASE_URL?.trim() || env.MEMOS_API_URL?.trim(); + const envApiKey = env.MEMOS_API_KEY?.trim(); + const storedKey = getApiKey('memos')?.trim(); + + const baseUrl = envBaseUrl || DEFAULT_BASE_URL; + const apiKey = envApiKey || storedKey || undefined; + const apiKeyHeader = env.MEMOS_API_KEY_HEADER?.trim() + || 'Authorization'; + const apiKeyScheme = env.MEMOS_API_KEY_SCHEME?.trim() + || 'Token'; + const searchPath = env.MEMOS_SEARCH_PATH?.trim() + || '/search/memory'; + const addPath = env.MEMOS_ADD_PATH?.trim() + || '/add/message'; + const timeoutMs = Number(env.MEMOS_TIMEOUT_MS || DEFAULT_TIMEOUT_MS); + const topK = Number(env.MEMOS_TOP_K || DEFAULT_TOP_K); + const maxContextLength = Number(env.MEMOS_MAX_CONTEXT_LENGTH || DEFAULT_MAX_CONTEXT_LENGTH); + const enabled = Boolean(baseUrl && apiKey); + + return { + enabled, + baseUrl, + apiKey, + apiKeyHeader, + apiKeyScheme, + searchPath, + addPath, + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : DEFAULT_TIMEOUT_MS, + topK: Number.isFinite(topK) ? topK : DEFAULT_TOP_K, + maxContextLength: Number.isFinite(maxContextLength) ? maxContextLength : DEFAULT_MAX_CONTEXT_LENGTH, + }; +} + +function resolveMemoryUserId(): string { + const env = getEnv(); + const fromEnv = env.MEMOS_USER_ID?.trim(); + return fromEnv || getMemoryUserId(); +} + +function buildUrl(baseUrl: string, path: string): string { + const trimmedBase = baseUrl.replace(/\/+$/, ''); + const trimmedPath = path.startsWith('/') ? path : `/${path}`; + return `${trimmedBase}${trimmedPath}`; +} + +function buildAuthHeaders(config: MemoryConfig): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (!config.apiKey) return headers; + + const headerKey = config.apiKeyHeader; + const headerValue = headerKey.toLowerCase() === 'authorization' + ? `${config.apiKeyScheme} ${config.apiKey}` + : config.apiKey; + + headers[headerKey] = headerValue; + return headers; +} + +async function fetchWithTimeout( + url: string, + options: RequestInit, + timeoutMs: number +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function extractMemoryTexts(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + + const root = payload as Record; + const data = (root.data && typeof root.data === 'object') + ? (root.data as Record) + : root; + const candidates = + (Array.isArray(data.memory_detail_list) && data.memory_detail_list) || + (Array.isArray(data.text_mem) && data.text_mem) || + (Array.isArray(data.memories) && data.memories) || + (Array.isArray(data.data) && data.data) || + []; + + const preferenceCandidates = + (Array.isArray(data.preference_detail_list) && data.preference_detail_list) || []; + const toolCandidates = + (Array.isArray(data.tool_memory_detail_list) && data.tool_memory_detail_list) || []; + const preferenceNote = normalizeText(data.preference_note); + + const texts: string[] = []; + for (const entry of candidates) { + if (typeof entry === 'string') { + const normalized = normalizeText(entry); + if (normalized) texts.push(normalized); + continue; + } + if (entry && typeof entry === 'object') { + const entryObj = entry as Record; + const memoryKey = normalizeText(entryObj.memory_key); + const memoryValue = + normalizeText(entryObj.memory_value) || + normalizeText(entryObj.text) || + normalizeText(entryObj.content) || + normalizeText(entryObj.memory); + if (memoryValue && memoryKey) { + texts.push(`${memoryKey}: ${memoryValue}`); + } else if (memoryValue) { + texts.push(memoryValue); + } + } + } + + for (const entry of preferenceCandidates) { + if (!entry || typeof entry !== 'object') continue; + const entryObj = entry as Record; + const preference = normalizeText(entryObj.preference); + const reasoning = normalizeText(entryObj.reasoning); + if (preference && reasoning) { + texts.push(`Preference: ${preference} (reason: ${reasoning})`); + } else if (preference) { + texts.push(`Preference: ${preference}`); + } + } + + for (const entry of toolCandidates) { + if (!entry || typeof entry !== 'object') continue; + const entryObj = entry as Record; + const toolValue = normalizeText(entryObj.tool_value); + const experience = normalizeText(entryObj.experience); + if (toolValue && experience) { + texts.push(`Tool memory: ${toolValue} (experience: ${experience})`); + } else if (toolValue) { + texts.push(`Tool memory: ${toolValue}`); + } else if (experience) { + texts.push(`Tool experience: ${experience}`); + } + } + + if (preferenceNote) { + texts.push(preferenceNote); + } + return texts; +} + +function formatMemoryContext(entries: string[], maxLength: number): string | null { + if (entries.length === 0) return null; + + const lines = [ + 'Relevant memories (treat as factual context; use when the user asks):', + ]; + for (const entry of entries) { + lines.push(`- ${entry}`); + } + const combined = lines.join('\n'); + if (combined.length <= maxLength) return combined; + + return combined.slice(0, Math.max(0, maxLength - 3)) + '...'; +} + +function toMemoryMessages(messages: TaskMessage[], taskPrompt?: string, summary?: string): MemoryMessage[] { + const filtered: MemoryMessage[] = messages + .filter((message) => message.type === 'user' || message.type === 'assistant') + .map((message): MemoryMessage => ({ + role: message.type === 'user' ? 'user' : 'assistant', + content: message.content.trim(), + })) + .filter((message) => message.content.length > 0); + + const recent = filtered.slice(-DEFAULT_MAX_MESSAGE_COUNT); + const normalized: MemoryMessage[] = recent.map((message): MemoryMessage => ({ + role: message.role, + content: message.content.slice(0, DEFAULT_MAX_MESSAGE_LENGTH), + })); + + if (normalized.length === 0 && taskPrompt) { + normalized.push({ role: 'user', content: taskPrompt.slice(0, DEFAULT_MAX_MESSAGE_LENGTH) }); + } + + if (summary) { + normalized.push({ + role: 'assistant', + content: `Summary: ${summary.slice(0, DEFAULT_MAX_MESSAGE_LENGTH)}`, + }); + } + + return normalized; +} + +export async function getMemoryContextForPrompt( + prompt: string, + conversationId?: string +): Promise { + const config = resolveMemoryConfig(); + if (!config.enabled || !config.baseUrl) return null; + + const payload = { + user_id: resolveMemoryUserId(), + query: prompt, + top_k: config.topK, + conversation_id: conversationId, + }; + + try { + const response = await fetchWithTimeout( + buildUrl(config.baseUrl, config.searchPath), + { + method: 'POST', + headers: buildAuthHeaders(config), + body: JSON.stringify(payload), + }, + config.timeoutMs + ); + + if (!response.ok) { + console.warn('[Memory] Search failed:', response.status, response.statusText); + return null; + } + + const data = await response.json().catch(() => null); + const entries = extractMemoryTexts(data); + return formatMemoryContext(entries.slice(0, config.topK), config.maxContextLength); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.warn('[Memory] Search timed out'); + return null; + } + console.warn('[Memory] Search failed:', error instanceof Error ? error.message : String(error)); + return null; + } +} + +export async function rememberTask(task: { + id: string; + prompt: string; + messages?: TaskMessage[]; + summary?: string; + status?: string; + createdAt?: string; + completedAt?: string; +}): Promise { + const config = resolveMemoryConfig(); + if (!config.enabled || !config.baseUrl) return; + + const messages = toMemoryMessages(task.messages ?? [], task.prompt, task.summary); + if (messages.length === 0) return; + + const payload = { + user_id: resolveMemoryUserId(), + conversation_id: task.id, + messages, + metadata: { + taskId: task.id, + status: task.status, + createdAt: task.createdAt, + completedAt: task.completedAt, + }, + }; + + try { + const response = await fetchWithTimeout( + buildUrl(config.baseUrl, config.addPath), + { + method: 'POST', + headers: buildAuthHeaders(config), + body: JSON.stringify(payload), + }, + config.timeoutMs + ); + + if (!response.ok) { + console.warn('[Memory] Add failed:', response.status, response.statusText); + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.warn('[Memory] Add timed out'); + return; + } + console.warn('[Memory] Add failed:', error instanceof Error ? error.message : String(error)); + } +} diff --git a/openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts b/openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts new file mode 100644 index 000000000..80d8e5bb0 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts @@ -0,0 +1,212 @@ +/** + * Task summary generator using LLM APIs + * + * Generates short, descriptive titles for tasks (like ChatGPT's conversation titles). + * Uses the first available API key, preferring Anthropic for speed/cost. + */ + +import { getApiKey, type ApiKeyProvider } from '../store/secureStorage'; + +const SUMMARY_PROMPT = `Generate a very short title (3-5 words max) that summarizes this task request. +The title should be in sentence case, no quotes, no punctuation at end. +Examples: "Check calendar", "Download invoice", "Search flights to Paris" + +Task: `; + +/** + * Generate a short summary title for a task prompt + * @param prompt The user's task prompt + * @returns A short summary string, or truncated prompt as fallback + */ +export async function generateTaskSummary(prompt: string): Promise { + // Try providers in order of preference + const providers: ApiKeyProvider[] = ['anthropic', 'openai', 'google', 'xai']; + + for (const provider of providers) { + const apiKey = getApiKey(provider); + if (!apiKey) continue; + + try { + const summary = await callProvider(provider, apiKey, prompt); + if (summary) { + console.log(`[Summarizer] Generated summary using ${provider}: "${summary}"`); + return summary; + } + } catch (error) { + console.warn(`[Summarizer] ${provider} failed:`, error); + // Continue to next provider + } + } + + // Fallback: truncate prompt + console.log('[Summarizer] All providers failed, using truncated prompt'); + return truncatePrompt(prompt); +} + +async function callProvider( + provider: ApiKeyProvider, + apiKey: string, + prompt: string +): Promise { + switch (provider) { + case 'anthropic': + return callAnthropic(apiKey, prompt); + case 'openai': + return callOpenAI(apiKey, prompt); + case 'google': + return callGoogle(apiKey, prompt); + case 'xai': + return callXAI(apiKey, prompt); + default: + return null; + } +} + +async function callAnthropic(apiKey: string, prompt: string): Promise { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-3-5-haiku-latest', + max_tokens: 50, + messages: [ + { + role: 'user', + content: SUMMARY_PROMPT + prompt, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`Anthropic API error: ${response.status}`); + } + + const data = (await response.json()) as { + content: Array<{ type: string; text?: string }>; + }; + const text = data.content?.[0]?.text; + return cleanSummary(text || ''); +} + +async function callOpenAI(apiKey: string, prompt: string): Promise { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + max_tokens: 50, + messages: [ + { + role: 'user', + content: SUMMARY_PROMPT + prompt, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + const text = data.choices?.[0]?.message?.content; + return cleanSummary(text || ''); +} + +async function callGoogle(apiKey: string, prompt: string): Promise { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + contents: [ + { + parts: [{ text: SUMMARY_PROMPT + prompt }], + }, + ], + generationConfig: { + maxOutputTokens: 50, + }, + }), + } + ); + + if (!response.ok) { + throw new Error(`Google API error: ${response.status}`); + } + + const data = (await response.json()) as { + candidates: Array<{ content: { parts: Array<{ text: string }> } }>; + }; + const text = data.candidates?.[0]?.content?.parts?.[0]?.text; + return cleanSummary(text || ''); +} + +async function callXAI(apiKey: string, prompt: string): Promise { + const response = await fetch('https://api.x.ai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'grok-3', + max_tokens: 50, + messages: [ + { + role: 'user', + content: SUMMARY_PROMPT + prompt, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`xAI API error: ${response.status}`); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + const text = data.choices?.[0]?.message?.content; + return cleanSummary(text || ''); +} + +/** + * Clean up the generated summary + */ +function cleanSummary(text: string): string { + return ( + text + // Remove surrounding quotes + .replace(/^["']|["']$/g, '') + // Remove trailing punctuation + .replace(/[.!?]+$/, '') + // Trim whitespace + .trim() + ); +} + +/** + * Fallback: truncate prompt to a reasonable length + */ +function truncatePrompt(prompt: string, maxLength = 30): string { + const cleaned = prompt.replace(/\s+/g, ' ').trim(); + if (cleaned.length <= maxLength) { + return cleaned; + } + return cleaned.slice(0, maxLength - 3) + '...'; +} diff --git a/openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts b/openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts new file mode 100644 index 000000000..81eaf96a6 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts @@ -0,0 +1,140 @@ +import Store from 'electron-store'; +import { randomUUID } from 'crypto'; +import type { SelectedModel, OllamaConfig, LiteLLMConfig } from '@accomplish/shared'; + +/** + * App settings schema + */ +interface AppSettingsSchema { + /** Enable debug mode to show backend logs in UI */ + debugMode: boolean; + /** Whether the user has completed the onboarding wizard */ + onboardingComplete: boolean; + /** Selected AI model (provider/model format) */ + selectedModel: SelectedModel | null; + /** Ollama server configuration */ + ollamaConfig: OllamaConfig | null; + /** LiteLLM proxy configuration */ + litellmConfig: LiteLLMConfig | null; + /** Stable user ID for memory services */ + memoryUserId: string; +} + +const appSettingsStore = new Store({ + name: 'app-settings', + defaults: { + debugMode: false, + onboardingComplete: false, + selectedModel: { + provider: 'anthropic', + model: 'anthropic/claude-opus-4-5', + }, + ollamaConfig: null, + litellmConfig: null, + memoryUserId: '', + }, +}); + +/** + * Get debug mode setting + */ +export function getDebugMode(): boolean { + return appSettingsStore.get('debugMode'); +} + +/** + * Set debug mode setting + */ +export function setDebugMode(enabled: boolean): void { + appSettingsStore.set('debugMode', enabled); +} + +/** + * Get onboarding complete setting + */ +export function getOnboardingComplete(): boolean { + return appSettingsStore.get('onboardingComplete'); +} + +/** + * Set onboarding complete setting + */ +export function setOnboardingComplete(complete: boolean): void { + appSettingsStore.set('onboardingComplete', complete); +} + +/** + * Get selected model + */ +export function getSelectedModel(): SelectedModel | null { + return appSettingsStore.get('selectedModel'); +} + +/** + * Set selected model + */ +export function setSelectedModel(model: SelectedModel): void { + appSettingsStore.set('selectedModel', model); +} + +/** + * Get Ollama configuration + */ +export function getOllamaConfig(): OllamaConfig | null { + return appSettingsStore.get('ollamaConfig'); +} + +/** + * Set Ollama configuration + */ +export function setOllamaConfig(config: OllamaConfig | null): void { + appSettingsStore.set('ollamaConfig', config); +} + +/** + * Get LiteLLM configuration + */ +export function getLiteLLMConfig(): LiteLLMConfig | null { + return appSettingsStore.get('litellmConfig'); +} + +/** + * Set LiteLLM configuration + */ +export function setLiteLLMConfig(config: LiteLLMConfig | null): void { + appSettingsStore.set('litellmConfig', config); +} + +/** + * Get or create stable memory user ID + */ +export function getMemoryUserId(): string { + let userId = appSettingsStore.get('memoryUserId'); + if (!userId) { + userId = randomUUID(); + appSettingsStore.set('memoryUserId', userId); + } + return userId; +} + +/** + * Get all app settings + */ +export function getAppSettings(): AppSettingsSchema { + return { + debugMode: appSettingsStore.get('debugMode'), + onboardingComplete: appSettingsStore.get('onboardingComplete'), + selectedModel: appSettingsStore.get('selectedModel'), + ollamaConfig: appSettingsStore.get('ollamaConfig') ?? null, + litellmConfig: appSettingsStore.get('litellmConfig') ?? null, + memoryUserId: appSettingsStore.get('memoryUserId'), + }; +} + +/** + * Clear all app settings (reset to defaults) + * Used during fresh install cleanup + */ +export function clearAppSettings(): void { + appSettingsStore.clear(); +} diff --git a/openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts b/openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts new file mode 100644 index 000000000..c27987933 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts @@ -0,0 +1,265 @@ +import { app } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import { clearAppSettings } from './appSettings'; +import { clearTaskHistoryStore } from './taskHistory'; +import { clearSecureStorage } from './secureStorage'; + +/** + * Fresh Install Cleanup + * + * Detects when the app has been reinstalled (e.g., from a new DMG) and clears + * old user data to ensure a clean first-run experience. + * + * Detection strategy: + * - Store the app bundle's modification timestamp + * - On startup, compare current bundle mtime with stored value + * - If different (or no stored value exists for a packaged app with existing data), + * it indicates a reinstall → clear old data + */ + +interface InstallMarker { + /** App bundle modification time (ISO string) */ + bundleMtime: string; + /** App version at install time */ + version: string; + /** Timestamp when marker was created */ + markerCreated: string; +} + +function getKnownUserDataDirs(): string[] { + const appDataPath = app.getPath('appData'); + const candidates = [ + app.getPath('userData'), + path.join(appDataPath, 'Accomplish'), + path.join(appDataPath, '@accomplish', 'desktop'), + path.join(appDataPath, 'ai.accomplish.desktop'), + path.join(appDataPath, 'com.accomplish.desktop'), + ]; + + return [...new Set(candidates)]; +} + +/** + * Get the path to the install marker file + */ +function getMarkerPath(): string { + return path.join(app.getPath('userData'), '.install-marker.json'); +} + +/** + * Get the app bundle's modification time + * For packaged apps, this is the .app bundle directory + * For dev mode, returns null (skip cleanup logic) + */ +function getAppBundleMtime(): Date | null { + if (!app.isPackaged) { + return null; + } + + // For macOS .app bundles, the executable is at: + // /Applications/Accomplish.app/Contents/MacOS/Accomplish + // We want the .app bundle directory + const execPath = app.getPath('exe'); + + // Find the .app bundle path + const appBundleMatch = execPath.match(/^(.+\.app)/); + if (!appBundleMatch) { + console.log('[FreshInstall] Could not determine app bundle path from:', execPath); + return null; + } + + const appBundlePath = appBundleMatch[1]; + + try { + const stats = fs.statSync(appBundlePath); + return stats.mtime; + } catch (err) { + console.error('[FreshInstall] Could not stat app bundle:', err); + return null; + } +} + +/** + * Read the stored install marker + */ +function readInstallMarker(): InstallMarker | null { + const markerPath = getMarkerPath(); + + try { + if (fs.existsSync(markerPath)) { + const content = fs.readFileSync(markerPath, 'utf-8'); + return JSON.parse(content) as InstallMarker; + } + } catch (err) { + console.error('[FreshInstall] Could not read install marker:', err); + } + + return null; +} + +/** + * Write the install marker + */ +function writeInstallMarker(marker: InstallMarker): void { + const markerPath = getMarkerPath(); + + try { + // Ensure userData directory exists + const userDataPath = app.getPath('userData'); + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); + } + + fs.writeFileSync(markerPath, JSON.stringify(marker, null, 2)); + console.log('[FreshInstall] Install marker saved'); + } catch (err) { + console.error('[FreshInstall] Could not write install marker:', err); + } +} + +/** + * Check if there's existing user data that would indicate a previous installation + */ +function hasExistingUserData(): boolean { + const dataDirs = getKnownUserDataDirs(); + const storeFiles = ['app-settings.json', 'task-history.json']; + + return dataDirs.some((dir) => + storeFiles.some((file) => fs.existsSync(path.join(dir, file))) + ); +} + +/** + * Clear all user data from previous installation + */ +function clearPreviousInstallData(): void { + console.log('[FreshInstall] Clearing data from previous installation...'); + + // Clear electron-store data using the store APIs + // This is important because stores are already initialized in memory + try { + clearAppSettings(); + console.log('[FreshInstall] - Cleared app settings store'); + } catch (err) { + console.error('[FreshInstall] - Failed to clear app settings:', err); + } + + try { + clearTaskHistoryStore(); + console.log('[FreshInstall] - Cleared task history store'); + } catch (err) { + console.error('[FreshInstall] - Failed to clear task history:', err); + } + + // Also delete any other config files that might exist + const userDataPath = app.getPath('userData'); + const filesToRemove = ['config.json', '.install-marker.json']; + + for (const file of filesToRemove) { + const filePath = path.join(userDataPath, file); + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[FreshInstall] - Removed: ${file}`); + } + } catch (err) { + console.error(`[FreshInstall] - Failed to remove ${file}:`, err); + } + } + + // Remove legacy data files from known previous locations + const legacyDirs = getKnownUserDataDirs().filter((dir) => dir !== userDataPath); + const legacyFiles = ['app-settings.json', 'task-history.json', 'config.json', '.install-marker.json']; + for (const dir of legacyDirs) { + for (const file of legacyFiles) { + const filePath = path.join(dir, file); + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[FreshInstall] - Removed legacy ${file} from ${dir}`); + } + } catch (err) { + console.error(`[FreshInstall] - Failed to remove legacy ${file} from ${dir}:`, err); + } + } + } + + // Clear secure storage (API keys stored via electron-store + safeStorage) + try { + clearSecureStorage(); + console.log('[FreshInstall] - Cleared secure storage'); + } catch (err) { + console.error('[FreshInstall] - Failed to clear secure storage:', err); + } + + console.log('[FreshInstall] Previous installation data cleared'); +} + +/** + * Check if this is a fresh install after a previous installation and perform cleanup + * + * Call this early in the app startup, before any stores are initialized. + * Returns true if cleanup was performed. + */ +export async function checkAndCleanupFreshInstall(): Promise { + // Skip in development mode + if (!app.isPackaged) { + console.log('[FreshInstall] Skipping fresh install check in dev mode'); + return false; + } + + const bundleMtime = getAppBundleMtime(); + if (!bundleMtime) { + console.log('[FreshInstall] Could not determine bundle mtime, skipping check'); + return false; + } + + const currentMtimeStr = bundleMtime.toISOString(); + const currentVersion = app.getVersion(); + const existingMarker = readInstallMarker(); + + // Case 1: No marker exists + if (!existingMarker) { + // Check if there's existing user data (from a previous install) + const hadExistingData = hasExistingUserData(); + if (hadExistingData) { + console.log('[FreshInstall] Found existing data but no install marker - this is a reinstall'); + clearPreviousInstallData(); + } else { + console.log('[FreshInstall] First time install (no previous data)'); + } + + // Create the install marker + writeInstallMarker({ + bundleMtime: currentMtimeStr, + version: currentVersion, + markerCreated: new Date().toISOString(), + }); + + return hadExistingData; + } + + // Case 2: Marker exists, check if bundle has changed + if (existingMarker.bundleMtime !== currentMtimeStr) { + console.log('[FreshInstall] App bundle has changed since last run'); + console.log(`[FreshInstall] Previous: ${existingMarker.bundleMtime}`); + console.log(`[FreshInstall] Current: ${currentMtimeStr}`); + + // Clear old data + clearPreviousInstallData(); + + // Update the marker + writeInstallMarker({ + bundleMtime: currentMtimeStr, + version: currentVersion, + markerCreated: new Date().toISOString(), + }); + + return true; + } + + // Case 3: Same installation, no cleanup needed + console.log('[FreshInstall] Same installation detected, no cleanup needed'); + return false; +} diff --git a/openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts b/openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts new file mode 100644 index 000000000..3d127fd44 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts @@ -0,0 +1,125 @@ +// apps/desktop/src/main/store/providerSettings.ts + +import Store from 'electron-store'; +import type { ProviderSettings, ProviderId, ConnectedProvider } from '@accomplish/shared'; + +const DEFAULT_SETTINGS: ProviderSettings = { + activeProviderId: null, + connectedProviders: {}, + debugMode: false, +}; + +const providerSettingsStore = new Store({ + name: 'provider-settings', + defaults: DEFAULT_SETTINGS, +}); + +export function getProviderSettings(): ProviderSettings { + return { + activeProviderId: providerSettingsStore.get('activeProviderId') ?? null, + connectedProviders: providerSettingsStore.get('connectedProviders') ?? {}, + debugMode: providerSettingsStore.get('debugMode') ?? false, + }; +} + +export function setActiveProvider(providerId: ProviderId | null): void { + providerSettingsStore.set('activeProviderId', providerId); +} + +export function getActiveProviderId(): ProviderId | null { + return providerSettingsStore.get('activeProviderId'); +} + +export function getConnectedProvider(providerId: ProviderId): ConnectedProvider | null { + const providers = providerSettingsStore.get('connectedProviders'); + return providers[providerId] ?? null; +} + +export function setConnectedProvider(providerId: ProviderId, provider: ConnectedProvider): void { + const providers = providerSettingsStore.get('connectedProviders'); + providerSettingsStore.set('connectedProviders', { + ...providers, + [providerId]: provider, + }); +} + +export function removeConnectedProvider(providerId: ProviderId): void { + const providers = providerSettingsStore.get('connectedProviders'); + const { [providerId]: _, ...rest } = providers; + providerSettingsStore.set('connectedProviders', rest); + + // If this was the active provider, clear it + if (providerSettingsStore.get('activeProviderId') === providerId) { + providerSettingsStore.set('activeProviderId', null); + } +} + +export function updateProviderModel(providerId: ProviderId, modelId: string | null): void { + const provider = getConnectedProvider(providerId); + if (provider) { + setConnectedProvider(providerId, { + ...provider, + selectedModelId: modelId, + }); + } +} + +export function setProviderDebugMode(enabled: boolean): void { + providerSettingsStore.set('debugMode', enabled); +} + +export function getProviderDebugMode(): boolean { + return providerSettingsStore.get('debugMode'); +} + +export function clearProviderSettings(): void { + providerSettingsStore.clear(); +} + +/** + * Get the active provider's model for CLI args + * Returns null if no active provider or no model selected + */ +export function getActiveProviderModel(): { provider: ProviderId; model: string; baseUrl?: string } | null { + const settings = getProviderSettings(); + const activeId = settings.activeProviderId; + + if (!activeId) return null; + + const activeProvider = settings.connectedProviders[activeId]; + if (!activeProvider || !activeProvider.selectedModelId) return null; + + const result: { provider: ProviderId; model: string; baseUrl?: string } = { + provider: activeId, + model: activeProvider.selectedModelId, + }; + + // Add baseUrl for Ollama/LiteLLM + if (activeProvider.credentials.type === 'ollama') { + result.baseUrl = activeProvider.credentials.serverUrl; + } else if (activeProvider.credentials.type === 'litellm') { + result.baseUrl = activeProvider.credentials.serverUrl; + } + + return result; +} + +/** + * Check if any provider is ready (connected with model selected) + */ +export function hasReadyProvider(): boolean { + const settings = getProviderSettings(); + return Object.values(settings.connectedProviders).some( + p => p && p.connectionStatus === 'connected' && p.selectedModelId !== null + ); +} + +/** + * Get all connected provider IDs for enabled_providers config + */ +export function getConnectedProviderIds(): ProviderId[] { + const settings = getProviderSettings(); + return Object.values(settings.connectedProviders) + .filter((p): p is ConnectedProvider => p !== undefined && p.connectionStatus === 'connected') + .map(p => p.providerId); +} diff --git a/openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts b/openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts new file mode 100644 index 000000000..5f4e05297 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts @@ -0,0 +1,269 @@ +import Store from 'electron-store'; +import { app } from 'electron'; +import * as crypto from 'crypto'; +import * as os from 'os'; + +/** + * Secure storage using electron-store with custom AES-256-GCM encryption. + * + * This implementation derives an encryption key from machine-specific values + * (hostname, platform, user home directory, app path) to avoid macOS Keychain + * prompts while still providing reasonable security for API keys. + * + * Security considerations: + * - Keys are encrypted at rest using AES-256-GCM + * - Encryption key is derived from machine-specific data (not stored) + * - Less secure than Keychain (key derivation could be reverse-engineered) + * - Suitable for API keys that can be rotated if compromised + */ + +// Use different store names for dev vs production to avoid conflicts +const getStoreName = () => (app.isPackaged ? 'secure-storage' : 'secure-storage-dev'); + +interface SecureStorageSchema { + /** Encrypted values stored as base64 strings (format: iv:authTag:ciphertext) */ + values: Record; + /** Salt for key derivation (generated once per installation) */ + salt?: string; +} + +// Lazy initialization to ensure app is ready +let _secureStore: Store | null = null; +let _derivedKey: Buffer | null = null; + +function getSecureStore(): Store { + if (!_secureStore) { + _secureStore = new Store({ + name: getStoreName(), + defaults: { values: {} }, + }); + } + return _secureStore; +} + +/** + * Get or create a salt for key derivation. + * The salt is stored in the config file and generated once per installation. + */ +function getSalt(): Buffer { + const store = getSecureStore(); + let saltBase64 = store.get('salt'); + + if (!saltBase64) { + // Generate a new random salt + const salt = crypto.randomBytes(32); + saltBase64 = salt.toString('base64'); + store.set('salt', saltBase64); + } + + return Buffer.from(saltBase64, 'base64'); +} + +/** + * Derive an encryption key from machine-specific data. + * This is deterministic for the same machine/installation. + * + * Note: We avoid hostname as it can be changed by users (renaming laptop). + */ +function getDerivedKey(): Buffer { + if (_derivedKey) { + return _derivedKey; + } + + // Combine machine-specific values to create a unique identifier + const machineData = [ + os.platform(), + os.homedir(), + os.userInfo().username, + app.getPath('userData'), + 'ai.accomplish.desktop', // App identifier + ].join(':'); + + const salt = getSalt(); + + // Use PBKDF2 to derive a 256-bit key + _derivedKey = crypto.pbkdf2Sync( + machineData, + salt, + 100000, // iterations + 32, // key length (256 bits) + 'sha256' + ); + + return _derivedKey; +} + +/** + * Encrypt a string using AES-256-GCM. + * Returns format: iv:authTag:ciphertext (all base64) + */ +function encryptValue(value: string): string { + const key = getDerivedKey(); + const iv = crypto.randomBytes(12); // GCM recommended IV size + + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + let encrypted = cipher.update(value, 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + const authTag = cipher.getAuthTag(); + + // Format: iv:authTag:ciphertext + return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; +} + +/** + * Decrypt a value encrypted with encryptValue. + */ +function decryptValue(encryptedData: string): string | null { + try { + const parts = encryptedData.split(':'); + if (parts.length !== 3) { + // Invalid format + return null; + } + + const [ivBase64, authTagBase64, ciphertext] = parts; + const key = getDerivedKey(); + const iv = Buffer.from(ivBase64, 'base64'); + const authTag = Buffer.from(authTagBase64, 'base64'); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch { + // Decryption failed (wrong key, corrupted data, etc.) + // Don't log error details to avoid leaking sensitive context + return null; + } +} + +/** + * Store an API key securely + */ +export function storeApiKey(provider: string, apiKey: string): void { + const store = getSecureStore(); + const encrypted = encryptValue(apiKey); + const values = store.get('values'); + values[`apiKey:${provider}`] = encrypted; + store.set('values', values); +} + +/** + * Retrieve an API key + */ +export function getApiKey(provider: string): string | null { + const store = getSecureStore(); + const values = store.get('values'); + if (!values) { + return null; + } + const encrypted = values[`apiKey:${provider}`]; + if (!encrypted) { + return null; + } + return decryptValue(encrypted); +} + +/** + * Delete an API key + */ +export function deleteApiKey(provider: string): boolean { + const store = getSecureStore(); + const values = store.get('values'); + const key = `apiKey:${provider}`; + if (!(key in values)) { + return false; + } + delete values[key]; + store.set('values', values); + return true; +} + +/** + * Supported API key providers + */ +export type ApiKeyProvider = 'anthropic' | 'openai' | 'openrouter' | 'google' | 'xai' | 'deepseek' | 'zai' | 'custom' | 'bedrock' | 'litellm'; + +/** + * Get all API keys for all providers + */ +export async function getAllApiKeys(): Promise> { + const [anthropic, openai, openrouter, google, xai, deepseek, zai, custom, bedrock, litellm] = await Promise.all([ + getApiKey('anthropic'), + getApiKey('openai'), + getApiKey('openrouter'), + getApiKey('google'), + getApiKey('xai'), + getApiKey('deepseek'), + getApiKey('zai'), + getApiKey('custom'), + getApiKey('bedrock'), + getApiKey('litellm'), + ]); + + return { anthropic, openai, openrouter, google, xai, deepseek, zai, custom, bedrock, litellm }; +} + +/** + * Store Bedrock credentials (JSON stringified) + */ +export function storeBedrockCredentials(credentials: string): void { + storeApiKey('bedrock', credentials); +} + +/** + * Get Bedrock credentials (returns parsed object or null) + */ +export function getBedrockCredentials(): Record | null { + const stored = getApiKey('bedrock'); + if (!stored) return null; + try { + return JSON.parse(stored); + } catch { + return null; + } +} + +/** + * Check if any API key is stored + */ +export async function hasAnyApiKey(): Promise { + const keys = await getAllApiKeys(); + return Object.values(keys).some((k) => k !== null); +} + +/** + * List all stored credentials for this service + * Returns key names with their (decrypted) values + */ +export function listStoredCredentials(): Array<{ account: string; password: string }> { + const store = getSecureStore(); + const values = store.get('values'); + const credentials: Array<{ account: string; password: string }> = []; + + for (const key of Object.keys(values)) { + const decrypted = decryptValue(values[key]); + if (decrypted) { + credentials.push({ + account: key, + password: decrypted, + }); + } + } + + return credentials; +} + +/** + * Clear all secure storage (used during fresh install cleanup) + */ +export function clearSecureStorage(): void { + const store = getSecureStore(); + store.clear(); + _derivedKey = null; // Clear cached key +} diff --git a/openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts b/openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts new file mode 100644 index 000000000..67e8b93e2 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts @@ -0,0 +1,224 @@ +import Store from 'electron-store'; +import type { Task, TaskMessage, TaskStatus } from '@accomplish/shared'; + +/** + * Task entry stored in history + */ +export interface StoredTask { + id: string; + prompt: string; + /** AI-generated short summary of the task (displayed in history) */ + summary?: string; + status: TaskStatus; + messages: TaskMessage[]; + sessionId?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; +} + +interface TaskHistorySchema { + tasks: StoredTask[]; + maxHistoryItems: number; +} + +const taskHistoryStore = new Store({ + name: 'task-history', + defaults: { + tasks: [], + maxHistoryItems: 100, + }, +}); + +const PERSIST_DEBOUNCE_MS = 250; +let pendingTasks: StoredTask[] | null = null; +let persistTimeout: NodeJS.Timeout | null = null; + +function getCurrentTasks(): StoredTask[] { + return pendingTasks ?? taskHistoryStore.get('tasks') ?? []; +} + +function schedulePersist(tasks: StoredTask[]): void { + pendingTasks = tasks; + if (persistTimeout) { + return; + } + persistTimeout = setTimeout(() => { + if (pendingTasks) { + taskHistoryStore.set('tasks', pendingTasks); + pendingTasks = null; + } + persistTimeout = null; + }, PERSIST_DEBOUNCE_MS); +} + +/** + * Immediately flush any pending task history writes to disk. + * Call this on app shutdown (e.g., 'before-quit' event) to prevent data loss. + */ +export function flushPendingTasks(): void { + if (persistTimeout) { + clearTimeout(persistTimeout); + persistTimeout = null; + } + if (pendingTasks) { + taskHistoryStore.set('tasks', pendingTasks); + pendingTasks = null; + } +} + +/** + * Get all tasks from history + */ +export function getTasks(): StoredTask[] { + return getCurrentTasks(); +} + +/** + * Get a specific task by ID + */ +export function getTask(taskId: string): StoredTask | undefined { + const tasks = getCurrentTasks(); + return tasks.find((t) => t.id === taskId); +} + +/** + * Save a new task to history + */ +export function saveTask(task: Task): void { + const tasks = getCurrentTasks(); + const maxItems = taskHistoryStore.get('maxHistoryItems'); + + const storedTask: StoredTask = { + id: task.id, + prompt: task.prompt, + summary: task.summary, + status: task.status, + messages: task.messages || [], + sessionId: task.sessionId, + createdAt: task.createdAt, + startedAt: task.startedAt, + completedAt: task.completedAt, + }; + + // Check if task already exists (update it) + const existingIndex = tasks.findIndex((t) => t.id === task.id); + if (existingIndex >= 0) { + tasks[existingIndex] = storedTask; + } else { + // Add new task at the beginning + tasks.unshift(storedTask); + } + + // Limit history size + if (tasks.length > maxItems) { + tasks.splice(maxItems); + } + + schedulePersist([...tasks]); +} + +/** + * Update a task's status + */ +export function updateTaskStatus( + taskId: string, + status: StoredTask['status'], + completedAt?: string +): void { + const tasks = getCurrentTasks(); + const taskIndex = tasks.findIndex((t) => t.id === taskId); + + if (taskIndex >= 0) { + tasks[taskIndex].status = status; + if (completedAt) { + tasks[taskIndex].completedAt = completedAt; + } + schedulePersist([...tasks]); + } +} + +/** + * Add a message to a task + */ +export function addTaskMessage(taskId: string, message: TaskMessage): void { + const tasks = getCurrentTasks(); + const taskIndex = tasks.findIndex((t) => t.id === taskId); + + if (taskIndex >= 0) { + tasks[taskIndex].messages.push(message); + schedulePersist([...tasks]); + } +} + +/** + * Update task's session ID + */ +export function updateTaskSessionId(taskId: string, sessionId: string): void { + const tasks = getCurrentTasks(); + const taskIndex = tasks.findIndex((t) => t.id === taskId); + + if (taskIndex >= 0) { + tasks[taskIndex].sessionId = sessionId; + schedulePersist([...tasks]); + } +} + +/** + * Update task's AI-generated summary + */ +export function updateTaskSummary(taskId: string, summary: string): void { + const tasks = getCurrentTasks(); + const taskIndex = tasks.findIndex((t) => t.id === taskId); + + if (taskIndex >= 0) { + tasks[taskIndex].summary = summary; + schedulePersist([...tasks]); + } +} + +/** + * Delete a task from history + */ +export function deleteTask(taskId: string): void { + const tasks = getCurrentTasks(); + const filteredTasks = tasks.filter((t) => t.id !== taskId); + schedulePersist(filteredTasks); +} + +/** + * Clear all task history + */ +export function clearHistory(): void { + schedulePersist([]); +} + +/** + * Set maximum history items + */ +export function setMaxHistoryItems(max: number): void { + taskHistoryStore.set('maxHistoryItems', max); + + // Trim existing history if needed + const tasks = getCurrentTasks(); + if (tasks.length > max) { + tasks.splice(max); + schedulePersist([...tasks]); + } +} + +/** + * Clear all task history data (reset store to defaults) + * Used during fresh install cleanup + */ +export function clearTaskHistoryStore(): void { + // Clear any pending writes + if (persistTimeout) { + clearTimeout(persistTimeout); + persistTimeout = null; + } + pendingTasks = null; + + // Clear the store (resets to defaults) + taskHistoryStore.clear(); +} diff --git a/openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts b/openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts new file mode 100644 index 000000000..5390b60bf --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts @@ -0,0 +1,363 @@ +/** + * Mock task flow utilities for E2E testing. + * Simulates IPC events without spawning real PTY processes. + */ +import { BrowserWindow } from 'electron'; +import type { Task, TaskMessage, TaskStatus } from '@accomplish/shared'; +import { updateTaskStatus } from '../store/taskHistory'; + +// ============================================================================ +// Types +// ============================================================================ + +export type MockScenario = + | 'success' + | 'with-tool' + | 'permission-required' + | 'question' + | 'error' + | 'interrupted'; + +export interface MockTaskConfig { + taskId: string; + prompt: string; + scenario: MockScenario; + /** Delay between events in milliseconds */ + delayMs?: number; +} + +// ============================================================================ +// E2E Mode Detection +// ============================================================================ + +/** + * Check if mock task events mode is enabled. + * Can be set via global flag, CLI arg, or environment variable. + */ +export function isMockTaskEventsEnabled(): boolean { + return ( + (global as Record).E2E_MOCK_TASK_EVENTS === true || + process.env.E2E_MOCK_TASK_EVENTS === '1' + ); +} + +// ============================================================================ +// Scenario Detection +// ============================================================================ + +/** + * Keywords that trigger specific test scenarios. + * Using explicit prefixes to avoid false positives from natural language. + */ +const SCENARIO_KEYWORDS: Record = { + success: ['__e2e_success__', 'test success'], + 'with-tool': ['__e2e_tool__', 'use tool', 'search files'], + 'permission-required': ['__e2e_permission__', 'write file', 'create file'], + question: ['__e2e_question__'], + error: ['__e2e_error__', 'cause error', 'trigger failure'], + interrupted: ['__e2e_interrupt__', 'stop task', 'cancel task'], +}; + +/** + * Detect the appropriate mock scenario from the prompt text. + * Checks for explicit keywords in priority order. + */ +export function detectScenarioFromPrompt(prompt: string): MockScenario { + const promptLower = prompt.toLowerCase(); + + // Check scenarios in priority order (error/interrupt first to handle edge cases) + const priorityOrder: MockScenario[] = [ + 'error', + 'interrupted', + 'question', + 'permission-required', + 'with-tool', + 'success', + ]; + + for (const scenario of priorityOrder) { + const keywords = SCENARIO_KEYWORDS[scenario]; + if (keywords.some(keyword => promptLower.includes(keyword.toLowerCase()))) { + return scenario; + } + } + + // Default to success + return 'success'; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function createMessageId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ============================================================================ +// Mock Task Execution +// ============================================================================ + +/** + * Execute a mock task flow by emitting simulated IPC events. + * This allows E2E tests to verify UI behavior without real API calls. + */ +export async function executeMockTaskFlow( + window: BrowserWindow, + config: MockTaskConfig +): Promise { + const { taskId, prompt, scenario, delayMs = 100 } = config; + + // Verify window is still valid + if (window.isDestroyed()) { + console.warn('[MockTaskFlow] Window destroyed, skipping mock flow'); + return; + } + + const sendEvent = (channel: string, data: unknown) => { + if (!window.isDestroyed()) { + window.webContents.send(channel, data); + } + }; + + // Initial progress event + sendEvent('task:progress', { taskId, stage: 'init' }); + await sleep(delayMs); + + // Assistant acknowledgment message + sendEvent('task:update', { + taskId, + type: 'message', + message: { + id: createMessageId(), + type: 'assistant', + content: `I'll help you with: ${prompt}`, + timestamp: new Date().toISOString(), + }, + }); + await sleep(delayMs); + + // Execute scenario-specific flow + await executeScenario(sendEvent, taskId, scenario, delayMs); +} + +/** + * Execute the scenario-specific event sequence. + */ +async function executeScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string, + scenario: MockScenario, + delayMs: number +): Promise { + switch (scenario) { + case 'success': + await executeSuccessScenario(sendEvent, taskId, delayMs); + break; + + case 'with-tool': + await executeToolScenario(sendEvent, taskId, delayMs); + break; + + case 'permission-required': + executePermissionScenario(sendEvent, taskId); + break; + + case 'question': + executeQuestionScenario(sendEvent, taskId); + break; + + case 'error': + executeErrorScenario(sendEvent, taskId); + break; + + case 'interrupted': + await executeInterruptedScenario(sendEvent, taskId, delayMs); + break; + } +} + +async function executeSuccessScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string, + delayMs: number +): Promise { + sendEvent('task:update', { + taskId, + type: 'message', + message: { + id: createMessageId(), + type: 'assistant', + content: 'Task completed successfully.', + timestamp: new Date().toISOString(), + }, + }); + await sleep(delayMs); + + // Update task history status before sending completion event + updateTaskStatus(taskId, 'completed', new Date().toISOString()); + + sendEvent('task:update', { + taskId, + type: 'complete', + result: { status: 'success', sessionId: `session_${taskId}` }, + }); +} + +async function executeToolScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string, + delayMs: number +): Promise { + // Simulate tool usage + sendEvent('task:update:batch', { + taskId, + messages: [ + { + id: createMessageId(), + type: 'tool', + content: 'Reading files', + toolName: 'Read', + timestamp: new Date().toISOString(), + }, + { + id: createMessageId(), + type: 'tool', + content: 'Searching code', + toolName: 'Grep', + timestamp: new Date().toISOString(), + }, + ], + }); + await sleep(delayMs * 2); + + sendEvent('task:update', { + taskId, + type: 'message', + message: { + id: createMessageId(), + type: 'assistant', + content: 'Found the information using available tools.', + timestamp: new Date().toISOString(), + }, + }); + await sleep(delayMs); + + // Update task history status before sending completion event + updateTaskStatus(taskId, 'completed', new Date().toISOString()); + + sendEvent('task:update', { + taskId, + type: 'complete', + result: { status: 'success', sessionId: `session_${taskId}` }, + }); +} + +function executePermissionScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string +): void { + // Send permission request - task waits for user response + // Tests should call permission:respond to continue the flow + sendEvent('permission:request', { + id: `perm_${Date.now()}`, + taskId, + type: 'file', + question: 'Allow file write?', + toolName: 'Write', + fileOperation: 'create', + filePath: '/test/output.txt', + timestamp: new Date().toISOString(), + }); +} + +function executeQuestionScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string +): void { + // Send question permission request - task waits for user to select an option + sendEvent('permission:request', { + id: `perm_${Date.now()}`, + taskId, + type: 'question', + header: 'Test Question', + question: 'Which option do you prefer?', + options: [ + { label: 'Option A', description: 'First option for testing' }, + { label: 'Option B', description: 'Second option for testing' }, + { label: 'Other', description: 'Enter a custom response' }, + ], + multiSelect: false, + timestamp: new Date().toISOString(), + }); +} + +function executeErrorScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string +): void { + // Update task history status before sending error event + updateTaskStatus(taskId, 'failed', new Date().toISOString()); + + sendEvent('task:update', { + taskId, + type: 'error', + error: 'Command execution failed: File not found', + }); +} + +async function executeInterruptedScenario( + sendEvent: (channel: string, data: unknown) => void, + taskId: string, + delayMs: number +): Promise { + sendEvent('task:update', { + taskId, + type: 'message', + message: { + id: createMessageId(), + type: 'assistant', + content: 'Task was interrupted by user.', + timestamp: new Date().toISOString(), + }, + }); + await sleep(delayMs); + + // Update task history status before sending completion event + updateTaskStatus(taskId, 'interrupted', new Date().toISOString()); + + sendEvent('task:update', { + taskId, + type: 'complete', + result: { status: 'interrupted', sessionId: `session_${taskId}` }, + }); +} + +// ============================================================================ +// Task Creation +// ============================================================================ + +/** + * Create a mock Task object for immediate return from task:start handler. + */ +export function createMockTask(taskId: string, prompt: string): Task { + const initialMessage: TaskMessage = { + id: createMessageId(), + type: 'user', + content: prompt, + timestamp: new Date().toISOString(), + }; + + return { + id: taskId, + prompt, + status: 'running', + messages: [initialMessage], + createdAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + }; +} diff --git a/openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts b/openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts new file mode 100644 index 000000000..41e12ecbd --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts @@ -0,0 +1,148 @@ +/** + * Utility module for accessing bundled Node.js binaries. + * + * The app bundles standalone Node.js v20.18.1 binaries to ensure + * MCP servers and CLI tools work regardless of the user's system configuration. + */ + +import { app } from 'electron'; +import path from 'path'; +import fs from 'fs'; + +const NODE_VERSION = '20.18.1'; + +export interface BundledNodePaths { + /** Path to the node executable */ + nodePath: string; + /** Path to the npm executable */ + npmPath: string; + /** Path to the npx executable */ + npxPath: string; + /** Directory containing the node binary */ + binDir: string; + /** Root directory of the Node.js installation */ + nodeDir: string; +} + +/** + * Get paths to the bundled Node.js binaries. + * + * In packaged apps, returns paths to the bundled Node.js installation. + * In development mode, returns null (use system Node.js). + * + * @returns Paths to bundled Node.js binaries, or null if not available + */ +export function getBundledNodePaths(): BundledNodePaths | null { + if (!app.isPackaged) { + // In development, use system Node + return null; + } + + const platform = process.platform; // 'darwin', 'win32', 'linux' + const arch = process.arch; // 'x64', 'arm64' + + const isWindows = platform === 'win32'; + const ext = isWindows ? '.exe' : ''; + const scriptExt = isWindows ? '.cmd' : ''; + + // Node.js directory is architecture-specific + const nodeDir = path.join( + process.resourcesPath, + 'nodejs', + arch // 'x64' or 'arm64' subdirectory + ); + + const binDir = isWindows ? nodeDir : path.join(nodeDir, 'bin'); + + return { + nodePath: path.join(binDir, `node${ext}`), + npmPath: path.join(binDir, `npm${scriptExt}`), + npxPath: path.join(binDir, `npx${scriptExt}`), + binDir, + nodeDir, + }; +} + +/** + * Check if bundled Node.js is available and accessible. + * + * @returns true if bundled Node.js exists and is accessible + */ +export function isBundledNodeAvailable(): boolean { + const paths = getBundledNodePaths(); + if (!paths) { + return false; + } + return fs.existsSync(paths.nodePath); +} + +/** + * Get the node binary path (bundled or system fallback). + * + * In packaged apps, returns the bundled node path. + * In development or if bundled node is unavailable, returns 'node' to use system PATH. + * + * @returns Absolute path to node binary or 'node' for system fallback + */ +export function getNodePath(): string { + const bundled = getBundledNodePaths(); + if (bundled && fs.existsSync(bundled.nodePath)) { + return bundled.nodePath; + } + // Warn if falling back to system node in packaged app (unexpected) + if (app.isPackaged) { + console.warn('[Bundled Node] WARNING: Bundled Node.js not found, falling back to system node'); + } + return 'node'; // Fallback to system node +} + +/** + * Get the npm binary path (bundled or system fallback). + * + * @returns Absolute path to npm binary or 'npm' for system fallback + */ +export function getNpmPath(): string { + const bundled = getBundledNodePaths(); + if (bundled && fs.existsSync(bundled.npmPath)) { + return bundled.npmPath; + } + if (app.isPackaged) { + console.warn('[Bundled Node] WARNING: Bundled npm not found, falling back to system npm'); + } + return 'npm'; // Fallback to system npm +} + +/** + * Get the npx binary path (bundled or system fallback). + * + * @returns Absolute path to npx binary or 'npx' for system fallback + */ +export function getNpxPath(): string { + const bundled = getBundledNodePaths(); + if (bundled && fs.existsSync(bundled.npxPath)) { + return bundled.npxPath; + } + if (app.isPackaged) { + console.warn('[Bundled Node] WARNING: Bundled npx not found, falling back to system npx'); + } + return 'npx'; // Fallback to system npx +} + +/** + * Log information about the bundled Node.js for debugging. + */ +export function logBundledNodeInfo(): void { + const paths = getBundledNodePaths(); + + if (!paths) { + console.log('[Bundled Node] Development mode - using system Node.js'); + return; + } + + console.log('[Bundled Node] Configuration:'); + console.log(` Platform: ${process.platform}`); + console.log(` Architecture: ${process.arch}`); + console.log(` Node directory: ${paths.nodeDir}`); + console.log(` Node path: ${paths.nodePath}`); + console.log(` Available: ${fs.existsSync(paths.nodePath)}`); +} diff --git a/openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts b/openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts new file mode 100644 index 000000000..5c7d0a473 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts @@ -0,0 +1,230 @@ +/** + * System PATH utilities for macOS packaged apps + * + * macOS GUI apps launched from /Applications don't inherit the user's terminal PATH. + * This module provides utilities to build a proper PATH without loading shell profiles, + * which avoids triggering macOS folder access permissions (TCC). + * + * We use two approaches: + * 1. /usr/libexec/path_helper - macOS official utility that reads /etc/paths and /etc/paths.d + * 2. Common Node.js installation paths - covers NVM, Volta, asdf, Homebrew, etc. + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Get NVM Node.js version paths. + * NVM stores versions in ~/.nvm/versions/node/vX.X.X/bin/ + * Returns paths sorted by version (newest first). + */ +function getNvmNodePaths(): string[] { + const home = process.env.HOME || ''; + const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); + + if (!fs.existsSync(nvmVersionsDir)) { + return []; + } + + try { + const versions = fs.readdirSync(nvmVersionsDir) + .filter(name => name.startsWith('v')) + .sort((a, b) => { + // Sort by version number (descending - newest first) + const parseVersion = (v: string) => { + const parts = v.replace('v', '').split('.').map(Number); + return parts[0] * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0); + }; + return parseVersion(b) - parseVersion(a); + }); + + return versions.map(v => path.join(nvmVersionsDir, v, 'bin')); + } catch { + return []; + } +} + +/** + * Get fnm Node.js version paths. + * fnm stores versions in ~/.fnm/node-versions/vX.X.X/installation/bin/ + */ +function getFnmNodePaths(): string[] { + const home = process.env.HOME || ''; + const fnmVersionsDir = path.join(home, '.fnm', 'node-versions'); + + if (!fs.existsSync(fnmVersionsDir)) { + return []; + } + + try { + const versions = fs.readdirSync(fnmVersionsDir) + .filter(name => name.startsWith('v')) + .sort((a, b) => { + const parseVersion = (v: string) => { + const parts = v.replace('v', '').split('.').map(Number); + return parts[0] * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0); + }; + return parseVersion(b) - parseVersion(a); + }); + + return versions.map(v => path.join(fnmVersionsDir, v, 'installation', 'bin')); + } catch { + return []; + } +} + +/** + * Common Node.js installation paths on macOS. + * These are checked in order of preference. + */ +function getCommonNodePaths(): string[] { + const home = process.env.HOME || ''; + + // Get dynamic paths from version managers + const nvmPaths = getNvmNodePaths(); + const fnmPaths = getFnmNodePaths(); + + return [ + // Version managers (dynamic - most specific, checked first) + ...nvmPaths, + ...fnmPaths, + + // Homebrew (very common) + '/opt/homebrew/bin', // Apple Silicon + '/usr/local/bin', // Intel Mac + + // Version managers (static fallbacks) + `${home}/.nvm/current/bin`, // NVM with 'current' symlink (optional) + `${home}/.volta/bin`, // Volta + `${home}/.asdf/shims`, // asdf + `${home}/.fnm/current/bin`, // fnm current symlink (optional) + `${home}/.nodenv/shims`, // nodenv + + // Less common but valid paths + '/usr/local/opt/node/bin', // Homebrew node formula + '/opt/local/bin', // MacPorts + `${home}/.local/bin`, // pip/pipx style installations + ].filter(p => p && !p.includes('undefined')); +} + +/** + * Get system PATH using macOS path_helper utility. + * This reads from /etc/paths and /etc/paths.d without loading user shell profiles. + * + * @returns The system PATH or null if path_helper fails + */ +function getSystemPathFromPathHelper(): string | null { + if (process.platform !== 'darwin') { + return null; + } + + try { + // path_helper outputs: PATH="..."; export PATH; + // We need to extract just the path value + const output = execSync('/usr/libexec/path_helper -s', { + encoding: 'utf-8', + timeout: 5000, + }); + + // Parse the output: PATH="/usr/local/bin:/usr/bin:..."; export PATH; + const match = output.match(/PATH="([^"]+)"/); + if (match && match[1]) { + return match[1]; + } + } catch (err) { + console.warn('[SystemPath] path_helper failed:', err); + } + + return null; +} + +/** + * Build an extended PATH for finding Node.js tools (node, npm, npx) in packaged apps. + * + * This function: + * 1. Gets the system PATH from path_helper (includes Homebrew if in /etc/paths.d) + * 2. Prepends common Node.js installation paths + * 3. Does NOT load user shell profiles (avoids TCC permission prompts) + * + * @param basePath - The base PATH to extend (defaults to process.env.PATH) + * @returns Extended PATH string + */ +export function getExtendedNodePath(basePath?: string): string { + const base = basePath || process.env.PATH || ''; + + if (process.platform !== 'darwin') { + // On non-macOS, just return the base PATH + return base; + } + + // Start with common Node.js paths + const nodePaths = getCommonNodePaths(); + + // Try to get system PATH from path_helper + const systemPath = getSystemPathFromPathHelper(); + + // Build the final PATH: + // 1. Common Node.js paths (highest priority - finds user's preferred Node) + // 2. System PATH from path_helper (includes /etc/paths.d entries) + // 3. Base PATH (fallback) + const pathParts: string[] = []; + + // Add common Node.js paths + for (const p of nodePaths) { + if (fs.existsSync(p) && !pathParts.includes(p)) { + pathParts.push(p); + } + } + + // Add system PATH from path_helper + if (systemPath) { + for (const p of systemPath.split(':')) { + if (p && !pathParts.includes(p)) { + pathParts.push(p); + } + } + } + + // Add base PATH entries + for (const p of base.split(':')) { + if (p && !pathParts.includes(p)) { + pathParts.push(p); + } + } + + return pathParts.join(':'); +} + +/** + * Check if a command exists in the given PATH. + * + * @param command - The command to find (e.g., 'npx', 'node') + * @param searchPath - The PATH to search in + * @returns The full path to the command if found, null otherwise + */ +export function findCommandInPath(command: string, searchPath: string): string | null { + for (const dir of searchPath.split(':')) { + if (!dir) continue; + + const fullPath = `${dir}/${command}`; + try { + if (fs.existsSync(fullPath)) { + const stats = fs.statSync(fullPath); + if (stats.isFile()) { + // Check if executable + try { + fs.accessSync(fullPath, fs.constants.X_OK); + return fullPath; + } catch { + // Not executable, continue searching + } + } + } + } catch { + // Directory doesn't exist or other error, continue + } + } + + return null; +} diff --git a/openwork-memos-integration/apps/desktop/src/preload/index.ts b/openwork-memos-integration/apps/desktop/src/preload/index.ts new file mode 100644 index 000000000..eed085b67 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/preload/index.ts @@ -0,0 +1,220 @@ +/** + * Preload Script for Local Renderer + * + * This preload script exposes a secure API to the local React renderer + * for communicating with the Electron main process via IPC. + */ + +import { contextBridge, ipcRenderer } from 'electron'; + +// Expose the accomplish API to the renderer +const accomplishAPI = { + // App info + getVersion: (): Promise => ipcRenderer.invoke('app:version'), + getPlatform: (): Promise => ipcRenderer.invoke('app:platform'), + + // Shell + openExternal: (url: string): Promise => + ipcRenderer.invoke('shell:open-external', url), + + // Task operations + startTask: (config: { description: string }): Promise => + ipcRenderer.invoke('task:start', config), + cancelTask: (taskId: string): Promise => + ipcRenderer.invoke('task:cancel', taskId), + interruptTask: (taskId: string): Promise => + ipcRenderer.invoke('task:interrupt', taskId), + getTask: (taskId: string): Promise => + ipcRenderer.invoke('task:get', taskId), + listTasks: (): Promise => ipcRenderer.invoke('task:list'), + deleteTask: (taskId: string): Promise => + ipcRenderer.invoke('task:delete', taskId), + clearTaskHistory: (): Promise => ipcRenderer.invoke('task:clear-history'), + + // Permission responses + respondToPermission: (response: { taskId: string; allowed: boolean }): Promise => + ipcRenderer.invoke('permission:respond', response), + + // Session management + resumeSession: (sessionId: string, prompt: string, taskId?: string): Promise => + ipcRenderer.invoke('session:resume', sessionId, prompt, taskId), + + // Settings + getApiKeys: (): Promise => ipcRenderer.invoke('settings:api-keys'), + addApiKey: ( + provider: 'anthropic' | 'openai' | 'openrouter' | 'google' | 'xai' | 'deepseek' | 'zai' | 'custom' | 'bedrock' | 'litellm', + key: string, + label?: string + ): Promise => + ipcRenderer.invoke('settings:add-api-key', provider, key, label), + removeApiKey: (id: string): Promise => + ipcRenderer.invoke('settings:remove-api-key', id), + getDebugMode: (): Promise => + ipcRenderer.invoke('settings:debug-mode'), + setDebugMode: (enabled: boolean): Promise => + ipcRenderer.invoke('settings:set-debug-mode', enabled), + getAppSettings: (): Promise<{ debugMode: boolean; onboardingComplete: boolean }> => + ipcRenderer.invoke('settings:app-settings'), + + // Memory (MemOS) configuration + getMemoryConfig: (): Promise<{ hasApiKey: boolean; apiKeyPrefix?: string }> => + ipcRenderer.invoke('memory:get-config'), + setMemoryApiKey: (key: string): Promise => + ipcRenderer.invoke('memory:set-api-key', key), + clearMemoryApiKey: (): Promise => + ipcRenderer.invoke('memory:clear-api-key'), + + // API Key management (new simplified handlers) + hasApiKey: (): Promise => + ipcRenderer.invoke('api-key:exists'), + setApiKey: (key: string): Promise => + ipcRenderer.invoke('api-key:set', key), + getApiKey: (): Promise => + ipcRenderer.invoke('api-key:get'), + validateApiKey: (key: string): Promise<{ valid: boolean; error?: string }> => + ipcRenderer.invoke('api-key:validate', key), + validateApiKeyForProvider: (provider: string, key: string): Promise<{ valid: boolean; error?: string }> => + ipcRenderer.invoke('api-key:validate-provider', provider, key), + clearApiKey: (): Promise => + ipcRenderer.invoke('api-key:clear'), + + // Onboarding + getOnboardingComplete: (): Promise => + ipcRenderer.invoke('onboarding:complete'), + setOnboardingComplete: (complete: boolean): Promise => + ipcRenderer.invoke('onboarding:set-complete', complete), + + // OpenCode CLI status + checkOpenCodeCli: (): Promise<{ + installed: boolean; + version: string | null; + installCommand: string; + }> => ipcRenderer.invoke('opencode:check'), + getOpenCodeVersion: (): Promise => + ipcRenderer.invoke('opencode:version'), + + // Model selection + getSelectedModel: (): Promise<{ provider: string; model: string; baseUrl?: string } | null> => + ipcRenderer.invoke('model:get'), + setSelectedModel: (model: { provider: string; model: string; baseUrl?: string }): Promise => + ipcRenderer.invoke('model:set', model), + + // Multi-provider API keys + getAllApiKeys: (): Promise> => + ipcRenderer.invoke('api-keys:all'), + hasAnyApiKey: (): Promise => + ipcRenderer.invoke('api-keys:has-any'), + + // Ollama configuration + testOllamaConnection: (url: string): Promise<{ + success: boolean; + models?: Array<{ id: string; displayName: string; size: number }>; + error?: string; + }> => ipcRenderer.invoke('ollama:test-connection', url), + + getOllamaConfig: (): Promise<{ baseUrl: string; enabled: boolean; lastValidated?: number; models?: Array<{ id: string; displayName: string; size: number }> } | null> => + ipcRenderer.invoke('ollama:get-config'), + + setOllamaConfig: (config: { baseUrl: string; enabled: boolean; lastValidated?: number; models?: Array<{ id: string; displayName: string; size: number }> } | null): Promise => + ipcRenderer.invoke('ollama:set-config', config), + + // OpenRouter configuration + fetchOpenRouterModels: (): Promise<{ + success: boolean; + models?: Array<{ id: string; name: string; provider: string; contextLength: number }>; + error?: string; + }> => ipcRenderer.invoke('openrouter:fetch-models'), + + // LiteLLM configuration + testLiteLLMConnection: (url: string, apiKey?: string): Promise<{ + success: boolean; + models?: Array<{ id: string; name: string; provider: string; contextLength: number }>; + error?: string; + }> => ipcRenderer.invoke('litellm:test-connection', url, apiKey), + + fetchLiteLLMModels: (): Promise<{ + success: boolean; + models?: Array<{ id: string; name: string; provider: string; contextLength: number }>; + error?: string; + }> => ipcRenderer.invoke('litellm:fetch-models'), + + getLiteLLMConfig: (): Promise<{ baseUrl: string; enabled: boolean; lastValidated?: number; models?: Array<{ id: string; name: string; provider: string; contextLength: number }> } | null> => + ipcRenderer.invoke('litellm:get-config'), + + setLiteLLMConfig: (config: { baseUrl: string; enabled: boolean; lastValidated?: number; models?: Array<{ id: string; name: string; provider: string; contextLength: number }> } | null): Promise => + ipcRenderer.invoke('litellm:set-config', config), + + // Bedrock + validateBedrockCredentials: (credentials: string) => + ipcRenderer.invoke('bedrock:validate', credentials), + saveBedrockCredentials: (credentials: string) => + ipcRenderer.invoke('bedrock:save', credentials), + getBedrockCredentials: () => + ipcRenderer.invoke('bedrock:get-credentials'), + + // Event subscriptions + onTaskUpdate: (callback: (event: unknown) => void) => { + const listener = (_: unknown, event: unknown) => callback(event); + ipcRenderer.on('task:update', listener); + return () => ipcRenderer.removeListener('task:update', listener); + }, + // Batched task updates for performance - multiple messages in single IPC call + onTaskUpdateBatch: (callback: (event: { taskId: string; messages: unknown[] }) => void) => { + const listener = (_: unknown, event: { taskId: string; messages: unknown[] }) => callback(event); + ipcRenderer.on('task:update:batch', listener); + return () => ipcRenderer.removeListener('task:update:batch', listener); + }, + onPermissionRequest: (callback: (request: unknown) => void) => { + const listener = (_: unknown, request: unknown) => callback(request); + ipcRenderer.on('permission:request', listener); + return () => ipcRenderer.removeListener('permission:request', listener); + }, + onTaskProgress: (callback: (progress: unknown) => void) => { + const listener = (_: unknown, progress: unknown) => callback(progress); + ipcRenderer.on('task:progress', listener); + return () => ipcRenderer.removeListener('task:progress', listener); + }, + onDebugLog: (callback: (log: unknown) => void) => { + const listener = (_: unknown, log: unknown) => callback(log); + ipcRenderer.on('debug:log', listener); + return () => ipcRenderer.removeListener('debug:log', listener); + }, + // Debug mode setting changes + onDebugModeChange: (callback: (data: { enabled: boolean }) => void) => { + const listener = (_: unknown, data: { enabled: boolean }) => callback(data); + ipcRenderer.on('settings:debug-mode-changed', listener); + return () => ipcRenderer.removeListener('settings:debug-mode-changed', listener); + }, + // Task status changes (e.g., queued -> running) + onTaskStatusChange: (callback: (data: { taskId: string; status: string }) => void) => { + const listener = (_: unknown, data: { taskId: string; status: string }) => callback(data); + ipcRenderer.on('task:status-change', listener); + return () => ipcRenderer.removeListener('task:status-change', listener); + }, + // Task summary updates (AI-generated summary) + onTaskSummary: (callback: (data: { taskId: string; summary: string }) => void) => { + const listener = (_: unknown, data: { taskId: string; summary: string }) => callback(data); + ipcRenderer.on('task:summary', listener); + return () => ipcRenderer.removeListener('task:summary', listener); + }, + + logEvent: (payload: { level?: string; message: string; context?: Record }) => + ipcRenderer.invoke('log:event', payload), +}; + +// Expose the API to the renderer +contextBridge.exposeInMainWorld('accomplish', accomplishAPI); + +// Also expose shell info for compatibility checks +const packageVersion = process.env.npm_package_version; +if (!packageVersion) { + throw new Error('Package version is not defined. Build is misconfigured.'); +} +contextBridge.exposeInMainWorld('accomplishShell', { + version: packageVersion, + platform: process.platform, + isElectron: true, +}); + +// Type declarations +export type AccomplishAPI = typeof accomplishAPI; diff --git a/openwork-memos-integration/apps/desktop/src/renderer/App.tsx b/openwork-memos-integration/apps/desktop/src/renderer/App.tsx new file mode 100644 index 000000000..316b02606 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/renderer/App.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { AnimatePresence, motion } from 'framer-motion'; +import { isRunningInElectron, getAccomplish } from './lib/accomplish'; +import { springs, variants } from './lib/animations'; +import { analytics } from './lib/analytics'; + +// Pages +import HomePage from './pages/Home'; +import ExecutionPage from './pages/Execution'; + +// Components +import Sidebar from './components/layout/Sidebar'; +import { TaskLauncher } from './components/TaskLauncher'; +import { useTaskStore } from './stores/taskStore'; +import { Loader2, AlertTriangle } from 'lucide-react'; + +type AppStatus = 'loading' | 'ready' | 'error'; + +export default function App() { + const [status, setStatus] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(null); + const location = useLocation(); + + // Get launcher actions + const { openLauncher } = useTaskStore(); + + // Track page views on route changes + useEffect(() => { + analytics.trackPageView(location.pathname); + }, [location.pathname]); + + // Cmd+K keyboard shortcut + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + openLauncher(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [openLauncher]); + + useEffect(() => { + const checkStatus = async () => { + // Check if running in Electron + if (!isRunningInElectron()) { + setErrorMessage('This application must be run inside the Openwork desktop app.'); + setStatus('error'); + return; + } + + try { + const accomplish = getAccomplish(); + // Mark onboarding as complete (no welcome screen needed) + await accomplish.setOnboardingComplete(true); + setStatus('ready'); + } catch (error) { + console.error('Failed to initialize app:', error); + // Still allow app to run even if setting fails + setStatus('ready'); + } + }; + + checkStatus(); + }, []); + + // Loading state + if (status === 'loading') { + return ( +
+ +
+ ); + } + + // Error state + if (status === 'error') { + return ( +
+
+
+
+ +
+
+

Unable to Start

+

{errorMessage}

+
+
+ ); + } + + // Ready - render the app with sidebar + return ( +
+ {/* Invisible drag region for window dragging (macOS hiddenInset titlebar) */} +
+ +
+ + + + + + } + /> + + + + } + /> + } /> + + +
+ +
+ ); +} diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx b/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx new file mode 100644 index 000000000..880c7df28 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Search, Plus, X } from 'lucide-react'; +import { useTaskStore } from '@/stores/taskStore'; +import { getAccomplish } from '@/lib/accomplish'; +import { cn } from '@/lib/utils'; +import { springs } from '@/lib/animations'; +import TaskLauncherItem from './TaskLauncherItem'; +import { hasAnyReadyProvider } from '@accomplish/shared'; + +export default function TaskLauncher() { + const navigate = useNavigate(); + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + + const { + isLauncherOpen, + closeLauncher, + tasks, + startTask + } = useTaskStore(); + const accomplish = getAccomplish(); + + // Filter tasks by search query (title only) + const filteredTasks = useMemo(() => { + if (!searchQuery.trim()) { + // Show last 7 days when no search + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + return tasks.filter(t => new Date(t.createdAt).getTime() > sevenDaysAgo); + } + const query = searchQuery.toLowerCase(); + return tasks.filter(t => t.prompt.toLowerCase().includes(query)); + }, [tasks, searchQuery]); + + // Total items: "New task" + filtered tasks + const totalItems = 1 + filteredTasks.length; + + // Reset state when modal opens + useEffect(() => { + if (isLauncherOpen) { + setSearchQuery(''); + setSelectedIndex(0); + // Focus input after animation + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isLauncherOpen]); + + // Clamp selected index when results change + useEffect(() => { + setSelectedIndex(i => Math.min(i, Math.max(0, totalItems - 1))); + }, [totalItems]); + + const handleSelect = useCallback(async (index: number) => { + if (index === 0) { + // "New task" selected + if (searchQuery.trim()) { + // Check if any provider is ready before starting task + const settings = await accomplish.getProviderSettings(); + if (!hasAnyReadyProvider(settings)) { + // No ready provider - navigate to home which will show settings + closeLauncher(); + navigate('/'); + return; + } + closeLauncher(); + const taskId = `task_${Date.now()}`; + const task = await startTask({ prompt: searchQuery.trim(), taskId }); + if (task) { + navigate(`/execution/${task.id}`); + } + } else { + // Navigate to home for empty input + closeLauncher(); + navigate('/'); + } + } else { + // Task selected - navigate to it + const task = filteredTasks[index - 1]; + if (task) { + closeLauncher(); + navigate(`/execution/${task.id}`); + } + } + }, [searchQuery, filteredTasks, closeLauncher, navigate, startTask, accomplish]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(i => Math.min(i + 1, totalItems - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(i => Math.max(i - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + handleSelect(selectedIndex); + break; + case 'Escape': + e.preventDefault(); + closeLauncher(); + break; + } + }, [totalItems, selectedIndex, handleSelect, closeLauncher]); + + return ( + !open && closeLauncher()}> + + {isLauncherOpen && ( + + {/* Overlay */} + + + + + {/* Content */} + + + {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search tasks..." + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + + + +
+ + {/* Results */} +
+ {/* New Task Option */} + + + {/* Task List */} + {filteredTasks.length > 0 && ( + <> +
+ {searchQuery.trim() ? 'Results' : 'Last 7 days'} +
+ {filteredTasks.slice(0, 10).map((task, i) => ( + handleSelect(i + 1)} + /> + ))} + + )} + + {/* Empty State */} + {searchQuery.trim() && filteredTasks.length === 0 && ( +
+ No tasks found +
+ )} +
+ + {/* Footer hint */} +
+ ↑↓ Navigate + Select + Esc Close +
+
+
+
+ )} +
+
+ ); +} diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx b/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx new file mode 100644 index 000000000..2eee34ebb --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx @@ -0,0 +1,64 @@ +'use client'; + +import type { Task } from '@accomplish/shared'; +import { cn } from '@/lib/utils'; +import { Loader2, CheckCircle2, XCircle, AlertCircle } from 'lucide-react'; + +interface TaskLauncherItemProps { + task: Task; + isSelected: boolean; + onClick: () => void; +} + +function formatRelativeDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) { + return date.toLocaleDateString('en-US', { weekday: 'long' }); + } + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function getStatusIcon(status: Task['status']) { + switch (status) { + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + case 'interrupted': + return ; + default: + return null; + } +} + +export default function TaskLauncherItem({ task, isSelected, onClick }: TaskLauncherItemProps) { + return ( + + ); +} diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts b/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts new file mode 100644 index 000000000..17437674c --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts @@ -0,0 +1,2 @@ +export { default as TaskLauncher } from './TaskLauncher'; +export { default as TaskLauncherItem } from './TaskLauncherItem'; diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx b/openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx new file mode 100644 index 000000000..520990ca9 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx @@ -0,0 +1,133 @@ +import { useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useTaskStore } from '../../stores/taskStore'; +import type { Task } from '@accomplish/shared'; + +interface TaskHistoryProps { + limit?: number; + showTitle?: boolean; +} + +export default function TaskHistory({ limit, showTitle = true }: TaskHistoryProps) { + const { tasks, loadTasks, deleteTask, clearHistory } = useTaskStore(); + + useEffect(() => { + loadTasks(); + }, [loadTasks]); + + const displayedTasks = limit ? tasks.slice(0, limit) : tasks; + + if (displayedTasks.length === 0) { + return ( +
+

No tasks yet. Start by describing what you want to accomplish.

+
+ ); + } + + return ( +
+ {showTitle && ( +
+

Recent Tasks

+ {tasks.length > 0 && !limit && ( + + )} +
+ )} + +
+ {displayedTasks.map((task) => ( + deleteTask(task.id)} + /> + ))} +
+ + {limit && tasks.length > limit && ( + + View all {tasks.length} tasks + + )} +
+ ); +} + +function TaskHistoryItem({ + task, + onDelete, +}: { + task: Task; + onDelete: () => void; +}) { + const statusConfig: Record = { + completed: { color: 'bg-success', label: 'Completed' }, + running: { color: 'bg-accent-blue', label: 'Running' }, + failed: { color: 'bg-danger', label: 'Failed' }, + cancelled: { color: 'bg-text-muted', label: 'Cancelled' }, + pending: { color: 'bg-warning', label: 'Pending' }, + waiting_permission: { color: 'bg-warning', label: 'Waiting' }, + }; + + const config = statusConfig[task.status] || statusConfig.pending; + const timeAgo = getTimeAgo(task.createdAt); + + return ( + +
+
+

+ {task.summary || task.prompt} +

+

+ {config.label} · {timeAgo} · {task.messages.length} messages +

+
+ + + ); +} + +function getTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} diff --git a/openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx b/openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx new file mode 100644 index 000000000..868ff02d2 --- /dev/null +++ b/openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import { getAccomplish } from '../../lib/accomplish'; +import { analytics } from '../../lib/analytics'; +import { CornerDownLeft, Loader2 } from 'lucide-react'; + +interface TaskInputBarProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + placeholder?: string; + isLoading?: boolean; + disabled?: boolean; + large?: boolean; + autoFocus?: boolean; +} + +export default function TaskInputBar({ + value, + onChange, + onSubmit, + placeholder = 'Assign a task or ask anything', + isLoading = false, + disabled = false, + large = false, + autoFocus = false, +}: TaskInputBarProps) { + const isDisabled = disabled || isLoading; + const textareaRef = useRef(null); + const accomplish = getAccomplish(); + + // Auto-focus on mount + useEffect(() => { + if (autoFocus && textareaRef.current) { + textareaRef.current.focus(); + } + }, [autoFocus]); + + // Auto-resize textarea + useEffect(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + } + }, [value]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + } + }; + + return ( +
+ {/* Text input */} +