From 39623ab856f79030a2e8664e884543a32de8a6cd Mon Sep 17 00:00:00 2001
From: Dubberman <48425266+whipser030@users.noreply.github.com>
Date: Fri, 6 Mar 2026 15:29:40 +0800
Subject: [PATCH 01/18] fix: The feedback function fails when calling the
search interface due to the failure in passing the 'user_name' parameter.
(#1174)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* add log
* add log
* add log
* hot fix
---------
Co-authored-by: 黑布林 <11641432+heiheiyouyou@user.noreply.gitee.com>
---
README.md | 34 +++++++++----------
docker/Dockerfile.krolik | 2 +-
.../data/mem_cube_tree/textual_memory.json | 2 +-
src/memos/graph_dbs/polardb.py | 2 +-
src/memos/mem_feedback/feedback.py | 9 ++---
src/memos/mem_os/utils/default_config.py | 2 ++
.../tree_text_memory/retrieve/searcher.py | 3 +-
7 files changed, 29 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 45a372ed1..834e53021 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@
-
+
@@ -55,7 +55,7 @@
-
+
Get Free API: [Try API](https://memos-dashboard.openmem.net/quickstart/?source=github)
@@ -68,11 +68,11 @@ Get Free API: [Try API](https://memos-dashboard.openmem.net/quickstart/?source=g

- [**72% lower token usage**](https://x.com/MemOS_dev/status/2020854044583924111) – intelligent memory retrieval instead of loading full chat history
-- [**Multi-agent memory sharing**](https://x.com/MemOS_dev/status/2020538135487062094) – multi-instance agents share memory via same user_id. Automatic context handoff.
+- [**Multi-agent memory sharing**](https://x.com/MemOS_dev/status/2020538135487062094) – multi-instance agents share memory via same user_id. Automatic context handoff.
🦞 Your lobster now has a working memory system.
-Get your API key: [MemOS Dashboard](https://memos-dashboard.openmem.net/cn/login/)
+Get your API key: [MemOS Dashboard](https://memos-dashboard.openmem.net/cn/login/)
Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTensor/MemOS-Cloud-OpenClaw-Plugin)
## 📌 MemOS: Memory Operating System for AI Agents
@@ -92,7 +92,7 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
### News
-- **2025-12-24** · 🎉 **MemOS v2.0: Stardust (星尘) Release**
+- **2025-12-24** · 🎉 **MemOS v2.0: Stardust (星尘) Release**
Comprehensive KB (doc/URL parsing + cross-project sharing), memory feedback & precise deletion, multi-modal memory (images/charts), tool memory for agent planning, Redis Streams scheduling + DB optimizations, streaming/non-streaming chat, MCP upgrade, and lightweight quick/full deployment.
✨ New Features
@@ -139,7 +139,7 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
-- **2025-08-07** · 🎉 **MemOS v1.0.0 (MemCube) Release**
+- **2025-08-07** · 🎉 **MemOS v1.0.0 (MemCube) Release**
First MemCube release with a word-game demo, LongMemEval evaluation, BochaAISearchRetriever integration, NebulaGraph support, improved search capabilities, and the official Playground launch.
@@ -177,11 +177,11 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
-- **2025-07-07** · 🎉 **MemOS v1.0: Stellar (星河) Preview Release**
+- **2025-07-07** · 🎉 **MemOS v1.0: Stellar (星河) Preview Release**
A SOTA Memory OS for LLMs is now open-sourced.
-- **2025-07-04** · 🎉 **MemOS Paper Release**
+- **2025-07-04** · 🎉 **MemOS Paper Release**
[MemOS: A Memory OS for AI System](https://arxiv.org/abs/2507.03724) is available on arXiv.
-- **2024-07-04** · 🎉 **Memory3 Model Release at WAIC 2024**
+- **2024-07-04** · 🎉 **Memory3 Model Release at WAIC 2024**
The Memory3 model, featuring a memory-layered architecture, was unveiled at the 2024 World Artificial Intelligence Conference.
@@ -194,9 +194,9 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
- Go to **API Keys** and copy your key
#### Next Steps
-- [MemOS Cloud Getting Started](https://memos-docs.openmem.net/memos_cloud/quick_start/)
+- [MemOS Cloud Getting Started](https://memos-docs.openmem.net/memos_cloud/quick_start/)
Connect to MemOS Cloud and enable memory in minutes.
-- [MemOS Cloud Platform](https://memos.openmem.net/?from=/quickstart/)
+- [MemOS Cloud Platform](https://memos.openmem.net/?from=/quickstart/)
Explore the Cloud dashboard, features, and workflows.
### 🖥️ 2、Self-Hosted (Local/Private)
@@ -234,7 +234,7 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
```python
import requests
import json
-
+
data = {
"user_id": "8736b16e-1d20-4163-980b-a5063c3facdc",
"mem_cube_id": "b32d0977-435d-4828-a86f-4f47f8b55bca",
@@ -250,7 +250,7 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
"Content-Type": "application/json"
}
url = "http://localhost:8000/product/add"
-
+
res = requests.post(url=url, headers=headers, data=json.dumps(data))
print(f"result: {res.json()}")
```
@@ -258,7 +258,7 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
```python
import requests
import json
-
+
data = {
"query": "What do I like",
"user_id": "8736b16e-1d20-4163-980b-a5063c3facdc",
@@ -268,7 +268,7 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
"Content-Type": "application/json"
}
url = "http://localhost:8000/product/search"
-
+
res = requests.post(url=url, headers=headers, data=json.dumps(data))
print(f"result: {res.json()}")
```
@@ -277,8 +277,8 @@ Try it: Full tutorial → [MemOS-Cloud-OpenClaw-Plugin](https://github.com/MemTe
## 📚 Resources
-- **Awesome-AI-Memory**
- This is a curated repository dedicated to resources on memory and memory systems for large language models. It systematically collects relevant research papers, frameworks, tools, and practical insights. The repository aims to organize and present the rapidly evolving research landscape of LLM memory, bridging multiple research directions including natural language processing, information retrieval, agentic systems, and cognitive science.
+- **Awesome-AI-Memory**
+ This is a curated repository dedicated to resources on memory and memory systems for large language models. It systematically collects relevant research papers, frameworks, tools, and practical insights. The repository aims to organize and present the rapidly evolving research landscape of LLM memory, bridging multiple research directions including natural language processing, information retrieval, agentic systems, and cognitive science.
- **Get started** 👉 [IAAR-Shanghai/Awesome-AI-Memory](https://github.com/IAAR-Shanghai/Awesome-AI-Memory)
- **MemOS Cloud OpenClaw Plugin**
Official OpenClaw lifecycle plugin for MemOS Cloud. It automatically recalls context from MemOS before the agent starts and saves the conversation back to MemOS after the agent finishes.
diff --git a/docker/Dockerfile.krolik b/docker/Dockerfile.krolik
index c475a6d30..dcae7e0d9 100644
--- a/docker/Dockerfile.krolik
+++ b/docker/Dockerfile.krolik
@@ -1,5 +1,5 @@
# MemOS with Krolik Security Extensions
-#
+#
# This Dockerfile builds MemOS with authentication, rate limiting, and admin API.
# It uses the overlay pattern to keep customizations separate from base code.
diff --git a/examples/data/mem_cube_tree/textual_memory.json b/examples/data/mem_cube_tree/textual_memory.json
index 91f426ca2..97a2b1dd0 100644
--- a/examples/data/mem_cube_tree/textual_memory.json
+++ b/examples/data/mem_cube_tree/textual_memory.json
@@ -4216,4 +4216,4 @@
"edges": [],
"total_nodes": 4,
"total_edges": 0
-}
\ No newline at end of file
+}
diff --git a/src/memos/graph_dbs/polardb.py b/src/memos/graph_dbs/polardb.py
index 8332efbc1..c8ed0a97f 100644
--- a/src/memos/graph_dbs/polardb.py
+++ b/src/memos/graph_dbs/polardb.py
@@ -1919,7 +1919,7 @@ def search_by_embedding(
else:
pass
- logger.info(" search_by_embedding query: %s", query)
+ logger.info(" search_by_embedding query: %s user_name: %s", query, user_name)
with self._get_connection() as conn, conn.cursor() as cursor:
if params:
diff --git a/src/memos/mem_feedback/feedback.py b/src/memos/mem_feedback/feedback.py
index 18045af2c..b8019004d 100644
--- a/src/memos/mem_feedback/feedback.py
+++ b/src/memos/mem_feedback/feedback.py
@@ -559,23 +559,24 @@ def check_has_edges(mem_item: TextualMemoryItem) -> tuple[TextualMemoryItem, boo
edges = self.searcher.graph_store.get_edges(mem_item.id, user_name=user_name)
return (mem_item, len(edges) == 0)
+ logger.info(f"[feedback _retrieve] query: {query}, user_name: {user_name}")
text_mems = self.searcher.search(
- query,
+ query=query,
+ top_k=top_k,
info=info,
memory_type="AllSummaryMemory",
user_name=user_name,
- top_k=top_k,
full_recall=True,
)
text_mems = [item[0] for item in text_mems if float(item[1]) > 0.01]
if self.pref_feedback:
pref_mems = self.searcher.search(
- query,
+ query=query,
+ top_k=top_k,
info=info,
memory_type="PreferenceMemory",
user_name=user_name,
- top_k=top_k,
include_preference_memory=True,
full_recall=True,
)
diff --git a/src/memos/mem_os/utils/default_config.py b/src/memos/mem_os/utils/default_config.py
index 9898cbe8c..de79d535d 100644
--- a/src/memos/mem_os/utils/default_config.py
+++ b/src/memos/mem_os/utils/default_config.py
@@ -4,12 +4,14 @@
"""
import logging
+
from typing import Literal
from memos.configs.mem_cube import GeneralMemCubeConfig
from memos.configs.mem_os import MOSConfig
from memos.mem_cube.general import GeneralMemCube
+
logger = logging.getLogger(__name__)
diff --git a/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py b/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py
index b4994671f..eb15b48ed 100644
--- a/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py
+++ b/src/memos/memories/textual/tree_text_memory/retrieve/searcher.py
@@ -92,7 +92,7 @@ def retrieve(
**kwargs,
) -> list[tuple[TextualMemoryItem, float]]:
logger.info(
- f"[RECALL] Start query='{query}', top_k={top_k}, mode={mode}, memory_type={memory_type}"
+ f"[RECALL] Start query='{query}', top_k={top_k}, mode={mode}, memory_type={memory_type}, user_name={user_name}"
)
parsed_goal, query_embedding, _context, query = self._parse_task(
query,
@@ -859,6 +859,7 @@ def _retrieve_from_skill_memory(
mode: str = "fast",
):
"""Retrieve and rerank from SkillMemory"""
+
if memory_type not in ["All", "SkillMemory"]:
logger.info(f"[PATH-E] '{query}' Skipped (memory_type does not match)")
return []
From 0a69ec21c033d5fb7907acbfee75880a13bf2c35 Mon Sep 17 00:00:00 2001
From: Hustzdy <67457465+wustzdy@users.noreply.github.com>
Date: Fri, 6 Mar 2026 15:59:38 +0800
Subject: [PATCH 02/18] feat:optimize config (#1176)
feat:optimize search_by_fulltext
---
src/memos/graph_dbs/polardb.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/memos/graph_dbs/polardb.py b/src/memos/graph_dbs/polardb.py
index c8ed0a97f..ad75f4b65 100644
--- a/src/memos/graph_dbs/polardb.py
+++ b/src/memos/graph_dbs/polardb.py
@@ -173,7 +173,7 @@ def __init__(self, config: PolarDBGraphDBConfig):
user=user,
password=password,
dbname=self.db_name,
- connect_timeout=60, # Connection timeout in seconds
+ connect_timeout=10, # Connection timeout in seconds
keepalives_idle=120, # Seconds of inactivity before sending keepalive (should be < server idle timeout)
keepalives_interval=15, # Seconds between keepalive retries
keepalives_count=5, # Number of keepalive retries before considering connection dead
@@ -247,7 +247,7 @@ def _warm_up_connections_by_all(self):
@contextmanager
def _get_connection(self):
- timeout = getattr(self, "_connection_wait_timeout", 5)
+ timeout = self._connection_wait_timeout
if timeout <= 0:
self._semaphore.acquire()
else:
@@ -1919,7 +1919,7 @@ def search_by_embedding(
else:
pass
- logger.info(" search_by_embedding query: %s user_name: %s", query, user_name)
+ logger.info(" search_by_embedding query: %s", query)
with self._get_connection() as conn, conn.cursor() as cursor:
if params:
From d5824caf86870bc2a21b108dada166dc646fefd9 Mon Sep 17 00:00:00 2001
From: tangbo <1502220175@qq.com>
Date: Sat, 7 Mar 2026 22:33:16 +0800
Subject: [PATCH 03/18] refactor: move openwork-memos-integration into apps/
directory
Reorganize project structure by moving the openwork-memos-integration
module under the apps/ directory for consistency.
Made-with: Cursor
---
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 3636 -> 0 bytes
.../apps/desktop/public/assets/logo.png | Bin 4605 -> 0 bytes
.../desktop/public/assets/openwork-icon.png | Bin 1003 -> 0 bytes
.../assets/usecases/ai-image-wizard.webp | Bin 2828 -> 0 bytes
.../assets/usecases/automated-reminders.webp | Bin 22282 -> 0 bytes
.../assets/usecases/batch-file-renaming.webp | Bin 49778 -> 0 bytes
.../assets/usecases/bilingual-output.webp | Bin 18690 -> 0 bytes
.../assets/usecases/calendar-events.webp | Bin 22008 -> 0 bytes
.../assets/usecases/calendar-prep-notes.png | Bin 12480 -> 0 bytes
.../assets/usecases/career-document.webp | Bin 23348 -> 0 bytes
.../assets/usecases/clean-data-output.webp | Bin 18054 -> 0 bytes
.../usecases/competitor-pricing-deck.png | Bin 31960 -> 0 bytes
.../assets/usecases/course-announcement.webp | Bin 49226 -> 0 bytes
.../assets/usecases/custom-web-tool.webp | Bin 21396 -> 0 bytes
.../assets/usecases/document-translation.webp | Bin 19248 -> 0 bytes
.../usecases/event-calendar-builder.png | Bin 10030 -> 0 bytes
.../assets/usecases/export-to-table.webp | Bin 45222 -> 0 bytes
.../assets/usecases/inbox-promo-cleanup.png | Bin 6400 -> 0 bytes
.../usecases/job-application-automation.png | Bin 3065 -> 0 bytes
.../assets/usecases/landing-page-copy.webp | Bin 20858 -> 0 bytes
.../assets/usecases/localize-content.webp | Bin 19108 -> 0 bytes
.../assets/usecases/notion-api-audit.png | Bin 5158 -> 0 bytes
.../public/assets/usecases/organize-data.webp | Bin 1836 -> 0 bytes
.../assets/usecases/personal-website.webp | Bin 1758 -> 0 bytes
.../public/assets/usecases/pitch-deck.webp | Bin 1430 -> 0 bytes
.../assets/usecases/polish-writing.webp | Bin 2928 -> 0 bytes
.../usecases/portfolio-presentation.webp | Bin 19626 -> 0 bytes
.../assets/usecases/prod-broken-links.png | Bin 31960 -> 0 bytes
.../assets/usecases/professional-emails.webp | Bin 1376 -> 0 bytes
.../usecases/professional-headshot.webp | Bin 27364 -> 0 bytes
.../usecases/staging-vs-prod-visual.png | Bin 33944 -> 0 bytes
.../usecases/stock-portfolio-alerts.png | Bin 6425 -> 0 bytes
.../desktop/public/fonts/DMSans-Black.ttf | Bin 56344 -> 0 bytes
.../apps/desktop/public/fonts/DMSans-Bold.ttf | Bin 56268 -> 0 bytes
.../desktop/public/fonts/DMSans-Light.ttf | Bin 56328 -> 0 bytes
.../desktop/public/fonts/DMSans-Medium.ttf | Bin 56376 -> 0 bytes
.../desktop/public/fonts/DMSans-Regular.ttf | Bin 56344 -> 0 bytes
.../desktop/resources/entitlements.mac.plist | 18 -
.../apps/desktop/resources/icon.png | Bin 26734 -> 0 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 4605469 -> 0 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 deletions(-)
delete mode 100644 openwork-memos-integration/CLAUDE.md
delete mode 100644 openwork-memos-integration/CONTRIBUTING.md
delete mode 100644 openwork-memos-integration/LICENSE
delete mode 100644 openwork-memos-integration/README.md
delete mode 100644 openwork-memos-integration/SECURITY.md
delete mode 100644 openwork-memos-integration/apps/desktop/.eslintrc.json
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/setup.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts
delete mode 100755 openwork-memos-integration/apps/desktop/clean_dmg_install.sh
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/README.md
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/config/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/playwright.config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/utils/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts
delete mode 100644 openwork-memos-integration/apps/desktop/index.html
delete mode 100644 openwork-memos-integration/apps/desktop/package.json
delete mode 100644 openwork-memos-integration/apps/desktop/postcss.config.js
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/logo-1.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/logo.png
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/openwork-icon.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/ai-image-wizard.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/automated-reminders.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/batch-file-renaming.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/bilingual-output.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/calendar-events.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/calendar-prep-notes.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/career-document.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/clean-data-output.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/competitor-pricing-deck.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/course-announcement.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/custom-web-tool.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/document-translation.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/event-calendar-builder.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/export-to-table.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/inbox-promo-cleanup.png
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/job-application-automation.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/landing-page-copy.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/localize-content.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/notion-api-audit.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/organize-data.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/personal-website.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/pitch-deck.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/polish-writing.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/portfolio-presentation.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/prod-broken-links.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/professional-emails.webp
delete mode 100644 openwork-memos-integration/apps/desktop/public/assets/usecases/professional-headshot.webp
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/staging-vs-prod-visual.png
delete mode 100755 openwork-memos-integration/apps/desktop/public/assets/usecases/stock-portfolio-alerts.png
delete mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Black.ttf
delete mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Bold.ttf
delete mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Light.ttf
delete mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Medium.ttf
delete mode 100644 openwork-memos-integration/apps/desktop/public/fonts/DMSans-Regular.ttf
delete mode 100644 openwork-memos-integration/apps/desktop/resources/entitlements.mac.plist
delete mode 100644 openwork-memos-integration/apps/desktop/resources/icon.png
delete mode 100755 openwork-memos-integration/apps/desktop/run_local_ui_prod_api.sh
delete mode 100755 openwork-memos-integration/apps/desktop/run_local_ui_staging_api.sh
delete mode 100755 openwork-memos-integration/apps/desktop/run_prod.sh
delete mode 100755 openwork-memos-integration/apps/desktop/run_staging.sh
delete mode 100644 openwork-memos-integration/apps/desktop/scripts/after-pack.cjs
delete mode 100644 openwork-memos-integration/apps/desktop/scripts/download-nodejs.cjs
delete mode 100644 openwork-memos-integration/apps/desktop/scripts/package.cjs
delete mode 100644 openwork-memos-integration/apps/desktop/scripts/patch-electron-name.cjs
delete mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/SKILL.md
delete mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/package-lock.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/package.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/src/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/ask-user-question/tsconfig.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/.gitignore
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/SKILL.md
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/bun.lock
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/package-lock.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/package.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/references/scraping.md
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-relay.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/scripts/start-server.ts
delete mode 100755 openwork-memos-integration/apps/desktop/skills/dev-browser/server.sh
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/client.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/relay.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/browser-script.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/snapshot/inject.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/src/types.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/tsconfig.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/dev-browser/vitest.config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/package-lock.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/package.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/src/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/skills/file-permission/tsconfig.json
delete mode 100644 openwork-memos-integration/apps/desktop/skills/safe-file-deletion/SKILL.md
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/ipc/handlers.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/ipc/validation.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/adapter.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/cli-path.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/config-generator.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/stream-parser.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/opencode/task-manager.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/permission-api.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/services/memory.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/services/summarizer.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/store/appSettings.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/store/freshInstallCleanup.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/store/providerSettings.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/store/secureStorage.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/store/taskHistory.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/test-utils/mock-task-flow.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/utils/bundled-node.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/main/utils/system-path.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/preload/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/App.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncher.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/TaskLauncherItem.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/TaskLauncher/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/history/TaskHistory.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/landing/TaskInputBar.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/ConversationListItem.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/Header.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/SettingsDialog.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/layout/Sidebar.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderCard.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderGrid.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/ProviderSettingsPanel.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/hooks/useProviderSettings.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/BedrockProviderForm.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/ClassicProviderForm.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/LiteLLMProviderForm.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OllamaProviderForm.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/OpenRouterProviderForm.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/providers/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ApiKeyInput.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectButton.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectedControls.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ConnectionStatus.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/FormError.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ModelSelector.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/ProviderFormHeader.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/RegionSelector.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/settings/shared/index.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/avatar.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/badge.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/button.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/card.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/dialog.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/dropdown-menu.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/input.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/label.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/scroll-area.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/separator.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/skeleton.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/streaming-text.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/components/ui/textarea.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/accomplish.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/analytics.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/animations.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/utils.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/lib/waiting-detection.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/main.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/pages/Execution.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/pages/History.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/pages/Home.tsx
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/stores/taskStore.ts
delete mode 100644 openwork-memos-integration/apps/desktop/src/renderer/styles/globals.css
delete mode 100644 openwork-memos-integration/apps/desktop/src/vite-env.d.ts
delete mode 100644 openwork-memos-integration/apps/desktop/tailwind.config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/tsconfig.json
delete mode 100644 openwork-memos-integration/apps/desktop/vite.config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/vitest.config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/vitest.integration.config.ts
delete mode 100644 openwork-memos-integration/apps/desktop/vitest.unit.config.ts
delete mode 100644 openwork-memos-integration/docs/banner.svg
delete mode 100644 openwork-memos-integration/docs/plans/2026-01-17-safe-file-deletion-impl.md
delete mode 100644 openwork-memos-integration/docs/video-thumbnail.png
delete mode 100644 openwork-memos-integration/package.json
delete mode 100644 openwork-memos-integration/packages/shared/package.json
delete mode 100644 openwork-memos-integration/packages/shared/src/index.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/auth.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/index.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/opencode.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/permission.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/provider.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/providerSettings.ts
delete mode 100644 openwork-memos-integration/packages/shared/src/types/task.ts
delete mode 100644 openwork-memos-integration/packages/shared/tsconfig.json
delete mode 100644 openwork-memos-integration/pnpm-lock.yaml
delete mode 100644 openwork-memos-integration/pnpm-workspace.yaml
diff --git a/openwork-memos-integration/CLAUDE.md b/openwork-memos-integration/CLAUDE.md
deleted file mode 100644
index 74a9707da..000000000
--- a/openwork-memos-integration/CLAUDE.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# 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';
-
-
-// WRONG - Absolute paths break in packaged app
-
-```
-
-**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
deleted file mode 100644
index b5dcb6dd1..000000000
--- a/openwork-memos-integration/CONTRIBUTING.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# 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
deleted file mode 100644
index e548c0a6f..000000000
--- a/openwork-memos-integration/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 132d7455a..000000000
--- a/openwork-memos-integration/README.md
+++ /dev/null
@@ -1,315 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-# 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.
-
-
-
-
-
-
-
-
- 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
deleted file mode 100644
index 16117e532..000000000
--- a/openwork-memos-integration/SECURITY.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# 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
deleted file mode 100644
index d655265ef..000000000
--- a/openwork-memos-integration/apps/desktop/.eslintrc.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "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
deleted file mode 100644
index 303dab022..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/appSettings.integration.test.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 7e0320988..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/cli-path.integration.test.ts
+++ /dev/null
@@ -1,499 +0,0 @@
-/**
- * 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
deleted file mode 100644
index e6636410e..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/opencode/config-generator.integration.test.ts
+++ /dev/null
@@ -1,332 +0,0 @@
-/**
- * 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
deleted file mode 100644
index f78a0355d..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/permission-api.integration.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * 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
deleted file mode 100644
index c5d57ce6c..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/secureStorage.integration.test.ts
+++ /dev/null
@@ -1,519 +0,0 @@
-/**
- * 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
deleted file mode 100644
index ed93e9a3d..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/store/freshInstallCleanup.integration.test.ts
+++ /dev/null
@@ -1,244 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 28e364870..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/taskHistory.integration.test.ts
+++ /dev/null
@@ -1,625 +0,0 @@
-/**
- * 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
deleted file mode 100644
index dd5f44c7b..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/bundled-node.integration.test.ts
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 9a6d6c268..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/main/utils/system-path.integration.test.ts
+++ /dev/null
@@ -1,513 +0,0 @@
-/**
- * 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
deleted file mode 100644
index c3e9c3341..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/preload/preload.integration.test.ts
+++ /dev/null
@@ -1,323 +0,0 @@
-/**
- * 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
deleted file mode 100644
index e1cde7814..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/App.integration.test.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-/**
- * 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 {children} ;
- },
- },
- 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
deleted file mode 100644
index 19a689328..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Header.integration.test.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-/**
- * 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
deleted file mode 100644
index b95408962..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/SettingsDialog.integration.test.tsx
+++ /dev/null
@@ -1,854 +0,0 @@
-/**
- * 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 }) => (
- {children}
- ),
-}));
-
-// 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
deleted file mode 100644
index 8a7a3a959..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/Sidebar.integration.test.tsx
+++ /dev/null
@@ -1,522 +0,0 @@
-/**
- * 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 ? (
-
- onOpenChange(false)}>Close Settings
-
- ) : 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 }) => (
- {children}
- ),
- },
- 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
deleted file mode 100644
index 8e82f31fc..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/StreamingText.integration.test.tsx
+++ /dev/null
@@ -1,487 +0,0 @@
-/**
- * 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) => (
-
-
- {text}
-
-
- )}
-
- );
-
- // 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
deleted file mode 100644
index 4ac675448..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskHistory.integration.test.tsx
+++ /dev/null
@@ -1,791 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 0abd2051c..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskInputBar.integration.test.tsx
+++ /dev/null
@@ -1,526 +0,0 @@
-/**
- * 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
deleted file mode 100644
index a4ca005bc..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/components/TaskLauncher.integration.test.tsx
+++ /dev/null
@@ -1,1122 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 7b213d2a7..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Execution.integration.test.tsx
+++ /dev/null
@@ -1,1380 +0,0 @@
-/**
- * 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 }) => (
- {children}
- ),
- },
- 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
deleted file mode 100644
index 2282232e8..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/pages/Home.integration.test.tsx
+++ /dev/null
@@ -1,594 +0,0 @@
-/**
- * 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 }) => (
- {children}
- ),
- },
- 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 ? (
-
- onOpenChange(false)}>Close
- {onApiKeySaved && (
- Save API Key
- )}
-
- ) : 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
deleted file mode 100644
index 135d5d2b5..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/integration/renderer/taskStore.integration.test.ts
+++ /dev/null
@@ -1,869 +0,0 @@
-/**
- * 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
deleted file mode 100644
index bb49b9c41..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/main/config.unit.test.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-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
deleted file mode 100644
index 3905889f2..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/main/ipc/handlers-utils.unit.test.ts
+++ /dev/null
@@ -1,784 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 807b1ebfa..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/main/ipc/validation.unit.test.ts
+++ /dev/null
@@ -1,617 +0,0 @@
-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
deleted file mode 100644
index 9554c46a6..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/main/opencode/stream-parser.unit.test.ts
+++ /dev/null
@@ -1,692 +0,0 @@
-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
deleted file mode 100644
index 372124cd7..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/renderer/lib/utils.unit.test.ts
+++ /dev/null
@@ -1,437 +0,0 @@
-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
deleted file mode 100644
index 5a0fde106..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/setup.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 1399ee222..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/main/ipc/handlers.unit.test.ts
+++ /dev/null
@@ -1,1946 +0,0 @@
-/**
- * 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
deleted file mode 100644
index f9e8d005b..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/adapter.unit.test.ts
+++ /dev/null
@@ -1,856 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 3b9a769e8..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/main/opencode/task-manager.unit.test.ts
+++ /dev/null
@@ -1,727 +0,0 @@
-/**
- * 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
deleted file mode 100644
index ff647538d..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/accomplish.unit.test.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/**
- * 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
deleted file mode 100644
index ae505cb3a..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/analytics.unit.test.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-/**
- * 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
deleted file mode 100644
index c878a3fd9..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/animations.unit.test.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 5dd871727..000000000
--- a/openwork-memos-integration/apps/desktop/__tests__/unit/renderer/lib/waiting-detection.unit.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-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
deleted file mode 100755
index 986e1c9f6..000000000
--- a/openwork-memos-integration/apps/desktop/clean_dmg_install.sh
+++ /dev/null
@@ -1,229 +0,0 @@
-#!/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
deleted file mode 100644
index 8ac2403e7..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/README.md
+++ /dev/null
@@ -1,236 +0,0 @@
-# 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
- Click me
- ```
-
-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
deleted file mode 100644
index 79d1a2429..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/config/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 2c8eab6f1..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/config/timeouts.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * 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
deleted file mode 100644
index cdfdbb54e..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/docker/Dockerfile
+++ /dev/null
@@ -1,52 +0,0 @@
-# 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
deleted file mode 100644
index eb0b6f3f3..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/docker/docker-compose.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index 3143ac79f..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/fixtures/electron-app.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index e403854a8..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/fixtures/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 60152a0f7..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/pages/execution.page.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-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
deleted file mode 100644
index 2e9ead2b9..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/pages/home.page.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-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
deleted file mode 100644
index 054baf888..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/pages/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index bb70b2d5b..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/pages/settings.page.ts
+++ /dev/null
@@ -1,247 +0,0 @@
-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
deleted file mode 100644
index 29ea63c26..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/playwright.config.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-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
deleted file mode 100644
index 91f533081..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/specs/execution.spec.ts
+++ /dev/null
@@ -1,618 +0,0 @@
-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
deleted file mode 100644
index 19c875d91..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/specs/home.spec.ts
+++ /dev/null
@@ -1,215 +0,0 @@
-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
deleted file mode 100644
index d52a1836b..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/specs/settings-bedrock.spec.ts
+++ /dev/null
@@ -1,187 +0,0 @@
-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
deleted file mode 100644
index 6dc99006d..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/specs/settings-providers.spec.ts
+++ /dev/null
@@ -1,351 +0,0 @@
-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
deleted file mode 100644
index 46fe5a1bc..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/specs/settings.spec.ts
+++ /dev/null
@@ -1,750 +0,0 @@
-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
deleted file mode 100644
index b7714f138..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/specs/task-launch-guard.spec.ts
+++ /dev/null
@@ -1,303 +0,0 @@
-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
deleted file mode 100644
index 3686ce8b4..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/utils/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 00ec0b573..000000000
--- a/openwork-memos-integration/apps/desktop/e2e/utils/screenshots.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 4a0055ba4..000000000
--- a/openwork-memos-integration/apps/desktop/index.html
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
- Openwork
-
-
-
-
-
-
-
-
-
diff --git a/openwork-memos-integration/apps/desktop/package.json b/openwork-memos-integration/apps/desktop/package.json
deleted file mode 100644
index 17680c922..000000000
--- a/openwork-memos-integration/apps/desktop/package.json
+++ /dev/null
@@ -1,178 +0,0 @@
-{
- "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
deleted file mode 100644
index 2aa7205d4..000000000
--- a/openwork-memos-integration/apps/desktop/postcss.config.js
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index a7434a4e1..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/anthropic.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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
deleted file mode 100644
index b9b9ddc24..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/bedrock.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 6e64a5274..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/deepseek.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index f9211621c..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/google.svg
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 0b84722f9..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/litellm.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 507ba05c1..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/ollama.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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
deleted file mode 100644
index 6b52fa907..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openai.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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
deleted file mode 100644
index ed2bd9b6e..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/openrouter.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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
deleted file mode 100644
index abc304973..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/provider-logos.svg
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 0016831bc..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/vertex.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
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
deleted file mode 100644
index 909d6d3ee..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/xai.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
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
deleted file mode 100644
index 9c1fd5cba..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/ai-logos/zai.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg b/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg
deleted file mode 100644
index b43c4aa55..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/icons/connect.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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
deleted file mode 100644
index 0e40bd387..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/icons/connected-key.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg b/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg
deleted file mode 100644
index 0ae338003..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/icons/connected.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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
deleted file mode 100644
index 85373f9fa..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/icons/pending-key.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg b/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg
deleted file mode 100755
index 1b5634182..000000000
--- a/openwork-memos-integration/apps/desktop/public/assets/loading-symbol.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/openwork-memos-integration/apps/desktop/public/assets/logo-1.png b/openwork-memos-integration/apps/desktop/public/assets/logo-1.png
deleted file mode 100644
index bb69319269160d32ee9b3ba695de3de3af081bd3..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 3636
zcmV-44$JY0P)
z4K(;MTJ7SgLahb_(D-aN8YCCE4mX+W+t+Y>4F;7;rLqvDcwSz^C*k^bxK7~_l}e?u
z5PO`Ii;b>@ilDqm2JL0a)pxJRW@`62mh=DK|ix53{B=-DdL+@U|=>hM0;48cV4
z&PtDwXe#mWlfpt}x#%PF9LX8KI*&7c`3Mt+42QRAl|-+VN#bYf-6uA8_;B;z~RX
z0^5Q-x6mt|Z}q6%BJZ=xwa1}SdEdOL@@WF08|FIaFn&P!VkISK@%9+ZQ##+eQms@<
zcvI!$0--L5w>WJ@LIq?{zBtM8yusT;7Taj)Eg7iH>Rhk9M^;llIfpYr4&nXq_XqUO
z)engZSm-#shL0$7VjJ{|j{g1o@bBl>u)}=r&|7&a4zJ-)^bYdpA#QX){|ve9l!kmk
zKcR1PrBYEqi#=xzgjUNDX$yc9g-yuY3^E;hCpRg+FNr!h%;z>!XAhlaW`?w>KByK@
zQ?WpQ8B2P-S@
zg{vtaQVyjGAbbFEkITjDAX^P{{VN{xX@#ay=mbsepAvD{pSi!^nO+H~h
zCJ1Yv7H%SW*C3SnEu4m4i?`_OSoYZ8t0^Dpv}-q1-~;mfH~9Mz<#gyRkgw>Ubkk-Gl1$Ph38vxxx7>D;Af7+Awt?1yAF0gUi06v}0d1
zn2$w&3P}GN=K9wmX#0%n9{am!u7zV}(6^7fk&Umh`~kUroD9mNotqZ?;(CPsl>$
zx*w1}*|o^`_2D)A8~XX_n6f-{vT4pXmc(g
z7H&~K=QVvTwI#$tZm%2CycD!0WGm!V`%(?^9b>UPJLCtzLp~wX_*0&QGS`Az6jX=t
z&fp=pLmEg;_yj#vmnl!DhwZsdl;1FUMhi~!?%WIVW4#g5X&;qHwM~N&@`I`89@G0;
zGtXNlj~T9g$dv7dblf$Yk-HW0H7F$KfL`m^*dcuc9%6>{4RpSs8(g<7fIKPfaNRT4
z_L!%5!g6xVQ2=qc6J#_(`{yV;ZZWiqG#su_j<$?=%eQ1-ZxeOl`hn(nPVu~8y7JjV
zw`SF(9MD=H%@hpyL7Lo<0q&u~B^&1u#As
z>Tq&^+M(VlnVceTgtkcXtWs7yA3T#rWA5*f?nAFAgX>2ZrAf6VY(E8RdxL2YuC_wj
z7e#PG8Qe81$di;Bt>IaEg4+sRW5E(0>B%;?yd}!iz$wyQqE2zxBRz-)>4|NHd`?LJ
zfc&JPyqr=!GHDy6+t*~|ImlMH4boZg3n0faQ@?g%878>Cg{*X3qFgFtm&j|FJgty6
z{!Qb2k8_1zdfIg-q&;FG10`GaEq82-M|$FW=<$#z
zLW}{wVAPI5$XoOHj@&?QpH|*2WW^l9)hD$GEciYzDTCY2hik+@R@4SMJoJjOMS4!{
zkXH;EatGKyzJ+D6$ZodZ8yQathxD*N<3c-bsDE%d}~V%#`vmSn)yKv60&--VH)L*1_VuF*#>xDGipH
z1WU*rC$q=AI}}IGPq)cjFQq}c{>?)24EVk>K^m@Y$Aa?QaLqCGm9{FJKV?MPnpjMO
z8{4>jE%N=8L#nfZ{N%pT6#SRW_014G&foBEUc*1I%u}?I+>m>$|1Q+uus%OAaUIee
zFdfd1MSVM-L~$olUooAIi8DwuMP4Vwo8a$fT=ty-UmwfF9fp6oWAYoJjCkIDj;_aW
zb<}cT;tcXW9O~~5^DyAQ_(1LXO>!Z7T>pr59khmt+v4;p#aucj?(bM1
zZesi|k)8%~PJ5-iH^a3X;hJP2_wTrbi#r5=m)>}CbV7V?44$zL)YnDIl#~zo
zQ|L}d+RQ<>2tC&QFuG)g7%bzP;W~*1&0;-nz)y_SfUPxA41Nqc@Gs_uSX}l;th5f%
zZ^PuVOr8#D)_6|NV0{iu9QErR3ZQM5L%%2-PENKC{h~05ymXCDN@yF_U|Dj{V|*Iq
zPKDa`4F1cC2dsl-;&OX_gLQb$T${5Ui1`z53vInV1iwe0&Dcg8rSEV#CiFX=FX8sh
zf|vUibXcE{!@o9)Z_t0`;s7Nc^WQTeWw4~leN@Q2$2=PFp8B=64qThUJFFM#GZ(J!
z#=HMLu6yd&EGSQVa%7R7JFT)l4fs<^f;)MNx7b{oIS=V-PS0BAx%zF{EVdAJm=PET02fj^@nS2|}>r<(JM$4xw
z$n#zqt=y2&KVxvIuq2D+5dF3|-Kb(eQin_HvBM<>(=kH)l2&U9>12!ggp{7a4CwCo
zG9heH#<<|k@Y@u~I$~ZUxjTbeb1N~BAH)jrSl+_h3uTUMgzIZre+{m0`4I8Lf1^d|
zpl>_ZS5AoM+GGpsoP!(N*ph0op5-$lSzt$G=27aj>vv4UL-(;Gy1qqUN!qsE2k}}c
z?E|Lsk%^-Yo4O!4sBH#1*&H7*4TJRaD}>~8nxgQeQ?hS?__S&~T=+GLKS1_}aeehG
z^!}cMQ1%_lG2p+ObS*Uz~-atLRgcwAbfIi(x>$i~lK#AQo;w=1l-#9k=J
zMCn@0Un|=BdMvb#wkTWtNAwm5b*{tZvGTaJYK8paB)wP2dm3bZ8S>8Ani$B6_K0bB
zZv(ll(zebc-R6=uZXvfPW&_IJkDk<)r$}VE#PY
zLchqlIp|SFNk2D2diBjS<;AAOPK~66Ko*Kol(j|PigH07pGoVnJT@bf+H4f%P?}tO
zosidO>M#zXfy@T!?;txNpQP|LhX@w)zUQ;YG)fi);gRm!BXk@y<>YP>J|X`$kuNv4
zhRM_7y2^*@D4m>dp=1ZyiqT-vPb$sUEgtmHIY{~Mael|yVI3Uub^S8b&*S_nf_uz=
zJis&QpM<#mP?kkMVef}}rTRP9Z!OZw*W%ol<1%>+Bcl<@oWhA83xPswNJ=t
z3)6E<=_^`CnlDZ+#?%KDO;pYX{6Y!uI5$`igJl+cs6dQDt{3uRW_>8H)^Z)6(U>YL
z_bJc@Z>xNEiF#|1d}O6-QezCuFU@__I6C90zlW
zyg8GjMVXfONcWE~ls|4+r{GY2XQsNEOt}r-R{2OLsgEe5ohYBfVOn2n7BE*&k22=8
z(+l~jy(afbjS}dt@;C^*Q|9>tdJZ_fU-LWG^ZkVKC>ZpUmSm>3dkx{~CHgIStn7
zYYgeTDDErDXqY(dyz)cZpP6e8E8&p0f~Gu6(ggWS^Df2sFrCH83A;pHJkpjASC5;a
z4mVi;fj)!f+g#xZ$JU_sbrBlp>08h790~RQpn8rBbP6
zA;oiclonmz1=kHKF&=f>RjCvczW%@|9%6JtPjNK=2zXTGQ~7~dYTHG=9okCK^C#g}
zD}?+$zo1g7R4U&T9@X-x{6H*y+EuAkDk`e-sZ=U&!v6tFw#E7Sek6MU0000pjnN)>(V)b55cmT#K55jRF7wP{Xv%V6`_m
ziV1_XHA$pEnS5b=$OS@7nhG2;uMK!m0U=K)MpBFLt5i=2I!6&&ga;-G-WW(G1IBil
zmYA5veO7V9EylS%3j~GIyowFfT>e?#{Ib^<9SS$qd$vhIh-;sV?P}
zqH!@};i-YUKc+YGLkAC>`;wTLT{0)z)Dmu<*0%38{+z5kyKFj}53SoBDbh;rSeq=}
z=Rz?U?cu8yvxOBOsB!N#^m7KzcLbB-!cM1M1Ao@Eilbq2!T8S{Ro^TR@tH-4m9Jv`L$Zn4Wabn-kyBB=Ny*U&&@e#KDg}bS4{u)_IMTLhVtdU
zT04PCmN{PR+LwMrQ!=%+AR;-Mnf0_~8h8Kte1&xV{B>V4%goV`^1^)3LO9_jny>4x
zR?`ihbVXb>epK9Uw0mr^Gi!px3^tzD^PjNFdly`7mA#>$s%(*tybcT9?;*>wc~iK-
z&YFq5)$3yVUi~X*VjP-~2Q^YscDC(-cVFFCv!AT7mh=1ZxGY0w)}~EL@7FYMD$Yi*ZSUR6i?m<5
zR}4BeMU-nbkyj^^-_8~yhibQG+nS)u&u`C03=fs&I_i4Cv^ax{+gsMMe`vML`s(_9
zhY4n<8{BWJov_E>{Jiew&hn+GVhuB-{AYIf^_P!pxW+?qYFNPjV$XVIRjpL}9@gUq
zul19LxVHUXO5GP4zdw9E`r%QRz40b;{u6+9?YnuFT$;G4(3DY;=Cpm})p&pxriYMH
zT$6VbeNM}ZUNojE9zMU<>OL+KIv>1|;cwl~6&aT3JO4Jd+Ix`@s3rr&gIJBA;+rXa
z9h}-@DQKg>r<>w3Nu1@Od}^bm`qIYyRK0OeUcu{MI!vQDpx?EdA-PXmvfCRlPh{*e
zO_yUC;E+bIOpdD4jiS{1z8C9FL-##ak>#1qeNd8WyUq~S5E6QYjhpv&5P|c-u1$y@
z;-hXL4Y>2som$<)2Lj-CZX<71&-g=2(%3)cg`ZB7;(`IIwvZ;*q0`83`JsD{2~hL632U!xuE@&<_duK#ijw{bDzDSiUO=v2#*`DMZzpRfWzYeZT4{a?
z>g9mF_T&H-@3t{XQJ80BV?f+B!EN-oCSp0i_!nluP5At=eXdG>!l13J_?ePDP%t*PHPk
zO6N0$Qj1WT@h2!>9gp@}#JDaIs*MD6h^vFfB5<#5gEKj#;rDuOD>!#heuYYOYjW`Q
zy~*qS4>W<(jn1d3%Gc!{Vr%)oI?wE82N#E(5NNp)2hcL)>UI94Z|Og8nD!Q_>*-lVgRUVJg-E>8IJQ`4!f`C2B9fN_0ILze>P*8QO|)
zd=&>CJbx1~VaZIX&Dfh7bT${n64l$KwHeE}-`hZeAj!|V-lW<(r(oyVHWhs)qSGn6
zO8u={C|=8DI$lm*#E3;aN;1iy7cIY(?3=$4d$
z#8!>xdX2Ni)Vujm!HfdGhjfz%P!lsd#FMM}&>G8{^H(bo4w9$k3Q?cq#}vTY&TGJb
zI@+4a#}R~Qa!P~wl-cm4JBmo$98_4r9DXwX@U&&dv#GHND|svNADg>5l1`dRBAP!P
zxR=i~SZr61>)oS1hZxgyS{%3F2KB5^9PiDnZ_n)pOxkW78+YJi#W^F+mrhG_JsY)4
zH?yXKyxP{7MNJT63N4(VUUJMiAu}GXycs=s^~frCdNYm1KCGc{^OibfKgXlC;u9FV
z>hK|c{NAs(0g|-)^%#g&A-w}sm<*cohvIiV30W2cRmbTMkMv3tH&DFX3S|x77NI)H
zW!d;9rUi@c%Yr*{Rn)&M(WyB1v~0tu?!-o)JAIpSiAO4SfWrJPw#wL3xcDsq=56d1
zd};%c-s}fbpINqaG$+wlV+h4Fod5Bs5H)V6TF=d8w+UDPYn(4Djn5BeUB4Y^&Jv%<
zy&f_CV>ov3aM8Zl^f&!$qRY*qRCp!4uzh%V)&yq?CXaf4B-EP^DM|rL@(R~mp_65{
z>d}_F!El#Sb=$F
zD_eLb@&w+6nb=Jq{lf{e^XX&akASZ4IVh`J2!)o9T`QlAnM9o5nI6oR?%!*kw0k^u
zTLzeWA4HEg=J4u`48CQ2eM8OaRD_)m=$s7ZT
zHeuLs;ZXPJIR37CC&^#68wz7+7NA-}t!wBPh!?VAEc8%VR<(1i%Qpq!-7gX#545j9
z@t7L9{C2eMCB>Qmop$n~OxXE8eP5@s?Ts3(g5Z@Dxn=Y1CRPTuuOrnKlP5NWb2M9q2@wO;4iI&N{&bSw$lNpT2G;AhP`&Qnm$@?C>Q0Hkj->6_2P03X7sq*YN
zcMP%r0eg*%`slv}yeVHG0tXaLIE-1@IQj}6T_BiiIi~r)P1;B9wm=#%^#Dnx+~8mC
zTTJ{qs(c8=(GH$so%O9r-o3JHBh4x$)@=qu`Bmnek|zrXijKMVnCYA76!EP99o!*Qk@c9YY3WqIMo-id5CfCQKUA|+k0eO
zCJ64gl420hgB)73rDS+s13Sqr!>>k&+Cj7AuLNetw-8BLknBZ$4!f5nuO%g=>59>~
zU$wbKt+M;S%jTae-v{W)`&{c*NYo0UXyq2d{1Bq05!9tE
zGsIUjgD6`@Z-1qhavld5oj1IdDtBR{=%k60s+i@|ryvXZo6AQfZBGMi-JtTm$84;x
zQuGjOWyF6z01hizN%QLhyuTO2#3?UC*3j00?zUWKzsF9x-*H79Vm>_ZAXk$LlPm6e
z>_FOTL3hLp5mz6#{4ge2p(yro&TWAce?s!-EqyExGi%a_6D_`pap^1ZQNt)C1*?J3Mi`Pb$Y}VGhg_mDNE55YkROz?_gRqBQSiQIkCw0$5rFHbklm^c
z$mPt4rY`pYnd;cZ`9+S4^s~-Y)fn(GDz*9}{K|~?P26Jz^7GRUv)G9QZ3FqR2CcmP
z?&a)eOwYNP0rR$f(M{<9&T6MJy|7D)1mU@2K`U0SMpcP1hAckj$)iDQ^MKwon|#5T
zKV=p0Nb9i^LHP2h+dGOJ5J?ldJ|?7>FFv@&_%)bDGL$sDuloC4eZ|4UD?}cc+W7Mk
zT7)?B7>b46pZF9_t{#Gytot5*5V9|+jT*@~e6%V5m5#Jf8Dt-
zpYCT=2Dg=7g7|Ztu=1>}MtxUD_9`G(CTBI8;^6a69`fH`qejYeiKMo^0=Sr-)?Ru&
zlM+cRyLn7rVu}MGJ`po$>Zy-4hWdhJ!fSffG{AaYy~i|ob3>weUi5HY
z+7bjH3h_`lG9}a6OgLVl!a*6ToTB#6xJtU-#x|$(tG-g@&@B7RYMDfo&ruJv{V}F=
zn}I^uJdRX{{od^ja$xmC@B_!Pz=kXft3|`BtE}&Ul6jp@G*&3U(7h@)!#$f`*6DpB
z#PV^aF)vyM7+#WnII5M%TEqQ*KTndRsrsyx7Ox_DI2<0H^?JP=*zI;rwKIbQ
zyH_}{TCFVE*=%+vf4N+&;l81Ez`gm%1BpmXCX-i?&kOGQ7do9zJ{SyY%Z)~(ybPLm
zBTQd-u~=9l(05fiXuk{?iB=r|aj9#Kd|be}2BT1z12E`VMiIzLjtEa)AMidT)|vxp
znpz^5jMM4#heKJGd5L4>;IOZ5{0;TO!K!l7{*VHuGHYh&@6U`B?)SIb&0bBz4`qP;
zeovdthPK-+U9VUB3}p;ad+m0+55MJmT)qw}*;E0O8|5$(4BSg;LHE#qU0ncVsNqFg
z3Gw9h0nr6R4svkD7waJ*4FV^*t{_PI5E9>^Z{IwTBY--Uj;KA&*Xwm@gLUhIYb+-X
zP_CwaeZ!DRS?|Th^)gA}86=1iN5q3H8*_kr&z*=dsZla>j-jyrGtj$@W(bT
z=h72fZGB<6&&R|I4WF(_z?sO-DE|YbD%EM0zl4{
z6NtFmY&_@ux~eV>FI7T=L<7kR<8(UF@pz>3`E2j`H?9DZACw;shh!Xhs0+yrfJDXx
zlAh&qY3tD@Gein6G33xUWKfq9a++BN%o8q)k>E6wPwXNWMar8nI9Qi$$p`~!$H3xk0U9(2u-I$fQvBO0_~1O!>4g<
z_HcLCG@T(Z?>7I9ZHxOK
Z_y%=#6mKNhMwb8p002ovPDHLkV1h<4$J+n^
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
deleted file mode 100644
index ce7e762613b297a60a304cec937c4858d9c9db73..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 2828
zcmV+n3-k0+Nk&El3jhFDMM6+kP&il$0000G000300093006|PpNIC-m00EFtZF}7|
zh9C%nAP9nB2x2fZm>ARy6b3E>K@bE%5QF`@&zt`I?{Yhh2ncT5NRs5*`);HMwyX97
z3%)-5=fi*gAug8ITP=B8qbKImkl(A<`j%4+4)eW5&a2gfa1;;u7aC-v_*u>gbQHau
z6X_@xQJ`LrH^WDCgqbqsUq
z`fMjX-d>tI+hs3RUF&8iUw)yeYu)T6t83ltWz*NXnX8rFc1l#&y4j1Tw_T#Hb;Gxv
z0pE5-L%)~S*qQ8QSJ%4z2iQ(?mH2HJK7qb<6Gv}5$FoW6mhb+pTYI`{-RdKtb@M*P
zH9BhBHGb0*)ua>LGq}TcWCRVi)B5GrwQjgJEZUF6>8%_6i|q`s+Yp(6FuQf5|3D>R
zviofK3
zI=OXY51tzY+hp6x9=tCi@WHd<(?sBd=YXKiZQa;|XP{uv+fI?Gts8vs%J}RU#F?!d
zeDJIoq?xT7eDD+u^4!*qeCIWCQ0BI7LT?2dlk3+sNMXdryAr!Ou5uHj7FDy
zG&qqj+LUfDyfz{Tm^%^?pQP>sw-;F*{pO;mqvCUb1W(E>xu%XCBzRJ8Nsot9@C>(C
z@LaXwZm;0Ey_^Qm?e+?uP8;j(6}-d4Y3Fs^T)}f!N4vR#_d9;@toq;&p7!#35W(TNCcimH2PnZwd7+Ke{=@fO>?eSPr)-=B-8|8FOE(ipu6hHU3t*vsO=U7C
zpl9|yC=UbMj1C9u>Fi->t~s{BS-^k$x5sLJoZR}*ENURd>dsgxA?Wi*ERp128}yCM
z`u%E4ca3R``NgkMOuz(l?y2xq#f5L(uJJyOL7HO>8I?B1PHP7aa=yh3~eDji7pX@`s#
zXkvXxCfPvC%K*&dr0ggfZjYr++L(ze?#F5*8C$G=ndTg&UVIWl>oOn2j
z@5PTwbJD~);2|rcPwrx&M;9;#f~Kr{(M^*N&k||12!zt@}G?qW8BNOl2Pd0i%
z-}0_L0!4oHXg84oNFx(I=k8>RGks3bpDFIdLLm@{L?RC00RH=rIH&uf3R8vS?YUv@dQnS@;4C;Kbw!vA^hq>UGV%^=_vNvNpC#A
zyK{g4=Ag|?D)&;zd8v9-(+VfG+IjPaEkF$4-INa4W*(S&s+z6|0Nr$#FuN|8jm09F
zl;1hZrnUxj)2sd5a>1QNE~#)pALHV%8iQEusL?w>lxv`1>w+@%AtKR?dx(KjJiikW
zZ5lY%;M5LrD>IJ15=GR>=MRme9EQ{X`H2bw+|r(0OA+81dpHV&oR74JT-+oB`3;;0
zo`1MFl#=1c!+XW1^HG
zbIV>K6zf=iVKw9YJJ;GvZ>k4@9`CVP9%fN=%{5RUytHBNY}(
z%D4eEG%#B!kw7*cW?Qj}SmPHkjo?I2a2qP~K=pO2JusbfW$yb`Vd`J93X;NpvFy{690q;&P8-D8Pd^^PvdiYS)!(I+v!X(~AFTI3~x
z-9Mn5=^FdK{?#p?ec_ASHBil#6}9%ZO`$Od_65(=zNux{W4ODr;R7QBFR~d(QhODg
ziBbemmus(W7vJIGK)zNy(2s5Ua&DXYjE8*0(*SKsj*DZ;;q+rHC?{*;7o=UCML55H
z7scrJx}9Kk5;2o+x|wL9@%ELZKkOIe389tGf#tcYRipHb4#v}bO0qw8O3bFftW`SZ
zA%iK)85E*+Q{psy2sREnV7kwG`+j~u-D^T^++GMQ+KIt9EUa`YT_;PoejF{Q!JE^3
zZE9!@o}r7e>s+a^>QmrqfwSJSZ9N&f0o%d7^S7R=T6vY-7;#7Gne3>pA}4iB=>z;0
z0QPq!Nf^Qd?0^*{71b_^EEXWtPs)1nWh8iN=
z6~u_=s}rG5(On*i(YZ}E9ABef7$bPY!uWQD_fr)SA6mJik44z8MAyvO&dNLRomWVL4rvP1>v!dZI_ZrCw4
z0DXvT$neeU35&)8;!SB3)3F7&r>?C9){of8z(ku6nLa<~G*F!VeMr?DpxaaiXrE-o
zx9*fLK@p{BK}m0BM9`Z>$G
ezwKV*l7I(f0#wd7|8$O~xByBp000000001__KThX
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
deleted file mode 100644
index 31e16c0933616b6490ce423f365cf38332fca558..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 22282
zcmaI6c|25K{69Wh%rF>3jD3c&MT{+Lj3H(aqLQ-A$WHc%EQ2v9WsF^xlxPtuvK3>A
zQ6r*=7D7eImNnbY`}_Ih^ZVob`<;8wxzGDLug5vBbM86kzRv4)97q-xASVF8=>)<4
zti9ej9smG9N+-31Q7mf|H1n~2)+XV=&b<&V8;K_JPHAT#uNYmHU7Ue>}>!*fCd0OANXI|
z|Mp1;C5-Zaih~@aAa8E~V5JHG;By85ga!cso(umc?;!dAkPUs1C3zrM@WJH+2n2Wm
z&;Sx32yhXgc7QYwKB@-bn5m6DaZBo;RBTe##Od$088jqPh&yi$Tfx0|0|8Xf=b
zopE9#!9Ltsr%X=}I2*_Vy-Ek!Sow@Fk2gbZP2tMIh{+80j=Pq^Q3wIc4i
zx~u|qace(#cfWCO|LaM4MCXnhQ>?X``8a1w|y$n`@9*ML#+Pg(^tQf{~%}{BqGceN&+HsIHZsd?!X?;>(xfs7cS2
z1~H5(f}SFX^5$NS0MYPhESU?2LJ^6<475!Uy^umqr)ANXPd*ccfjLca)8(pRIsoUB
zR<=XvaFm_{6r7O?^z){YCc#XA*6%xV*|bm^giNN|z$xNfFgl)Es4^_utpiq7tvACV
zp?HuF$bu-l_|B_f8cl3bMo{N(58@0!X6PCNG#1NLvw<>eUA3HGs&K5sS!ewoPNKPz
z*0ge={saS$gg%qd1vB75)VBcmw6%2!kWT5vViB_#1oOHP95%{13^s(k!9!`~%i2*k
z^E{ZpSwbNzHoz%m*n4?IK_P>}#WVy}U+le!gQVGwS4U(4I4*4LJe1WJ078-nPY#Na
zCyl+m6&C15);JEpVJcdX3WU-Uzsy46T?|*$l|BF*PDDdgM_GEEgVNtv*e~*=!9ys8
zL@Z_OJAdcQ?gR(FTWDaU2m#q?u8J)s6i}cT3W0OpjE*lzDGX4kn#E1)Xn9%|AQVEuHxOtBKnvG`
z=!6g=<-J8AAfPUXf#?h(N{JDPY1a)ya~UA;H2U7m7gz-lMW$n#l@c`qK+V}O63**(
zijfu=0`0?QGY~#V!U_j|7Z%m&Vnd@M47gZm5(j6j3;`vcqjywe3m3W-!8mXt78Y%X
zM3b043IgD<6q0w7kT;A_=+e)*`OObGc!-W!7E0L4kN*{Jd=$E3k1%b!hfaIJlN5WQ
zGzmR%*c>2e&Jb|l>nn?ur_gnpML@6~3>VX=KZ6Tuyx(Z@W`DwDyV-r~gUP<7JD;Ix
zkyukffTV})uv<%F0V(18sD8)d&WHV-tH7z1kJzx3n*d1*V*u&g^5PD}XLD>~_lFFx
zDpQmV5!EMfbLF|ZI@s)dFPlN_FQiT+({Zg$7N}GuV7VBIytx&n$8O&)YbR(D(MNGN
zHMmiOPk0%7YKmK9?OS{xI5-1dr$tPK(1FDDEp_Qb?VI0qUHD+^S8OdDHUlef_U|zN
z-`brvv59fQTTLLmn9&|1-oEJ$>r>+*j)~E*wx#J=dmO*my~{%V{)kc40Tz`*o%ygi
zZv_9msDf8=orBkTTI3_xSys$|Sssy}1tQcwEYMH@2af=1&IxO}dC4NEilB;jo=r7_
z2O=YSZ36Qgb9I1SF7C{*GxT9$%U;e7T%?>lNO+-*{Ur*4E_>5$g_H|#&o#~
zrfFOdg(MmD<&=PdSR4oaOs`iP3&XPi;*dln2WiO7A`baR>N$3~L>JUZdZ3$lGt@S=
z*X@xkK-UL;4D7c8cH!9X#3m?~DlZnnMK?pTZeRFo#K5;;J-abKO(eJ#aX!?^0g598sZ15c0ECCLqzB5b3ktM*rP={-`Gdeog1F9w-60{v
za?#T$tstY;;-U1W*S07N=67XcULgb4A0y3S0{W9EO+F0R!9omVhaOwT&G-W%B@#KY
zZ}NA}Dr}u0(`ghWD)Q
zNWcSxButMW^;kqXARMomI(bXW2Ji;Xi(`}gJawZRz=;L7VFbd8!hkssUkC_8!#gOL
z>%q_+=q@U?rd%j0`yX_P=&1-Rf;+WlWxeo5Va%*6W^`~@!>Kbi7zkHh#TO3dTY7i(
zm}iZeZZ6Y?;BCtTQ--l#!h{&`P|rzeTRbSDd>9sX=LZjifewfQNbHq@QReCHrtRakYQc$Vz8iWJFzsBcCgOD!Pe!*uL1B&mJut;
zsHUz5$EwN7u&ZDg5=&(f!Dhdpus&ikKcNoUn}WbgI`+oftZUSnk-)0*=!z;{nj|QZ
zGUCMq03GrX(8BIVjBjf0L9Pgyve74*?w-am*t~P>u8J!$VAU=LHHR7XyOTwO(_nN@
zA}fw(P##;CsXuq|nB>DOVm^F+uFjsTFDiwB?1N(vN`iz=EO06XAf-^d_T*zZmQ2bH
zeS@=pgVZX<(9O9qg?NQTgtsohEZGLc`Pj?9Dl}>abkUaf8A#Pq%^1}lX5-lqHlP(i
zU~?>ybcCp9eydlpp;TJdp#79A^|-q&z`830giXsLkrnWSAzpkNp72_dJ5h+m0*`et
zAAJub7`^loo2&^>W5oQEmk5%3`2{7Up8q&GiqS}xA-Fby)
zXE|ZBb!T3Ij3kmj55hsr(H!iK=zdz6do~iy_{2p)7KkyH)b|->?oLz$c0t9NVN_zY
zE1JL_sFO!B0Hi1=zEheAO<2v`663!CnHHz$;&0<%V9efOlRgZW9~EyD2TB(v5F!&+
zf0~&2F~}m!zN37+8q&NwnzePyIE=hYkOWbiLL97pY0eA%ZZpABa&FoyB3M
zim46##RM2>!*M&yqe|X_a2#QZUU;2MCilDvgk}-Z-=$cdPhc75uScPp-JryJ!zL&J
zp9WkJSJVcw-Ox{}c#*!E9Qf5NfHN@QtmZVGse}8o5S-=6bm8m#2_B^?)DVWO
zS49_dHrr3Om;%zh2)OI&@5Ok5>6{5GNe0?kCtq!MKE6JD3o?
zAwtd(8bwTP301?89Gu%^@ZQp;Lq!!>q0tMdvYaMy^<|T*S5s+yIvR3+<_K)fZp{wS
z*|N5mKoT-Zp8WY;Zh#QFnb4*uvp4ohg3H^;hFE7vZt~#rd%<$wX=v0XupwZa^(;Tu
zkzu60l(O}&)qUsN_C_#?E_O`5u;!zbiy1mm;pXaM_ibnr4Mt0(pa^8tx;Hr@U1sZ>
zO#HsFD@McjEIWmLFsQBZoi6k>BQy?!&6fdJC%d|$sGdXx(m5f5QBIR>wFkx8di95m#bJdAnlwQ6VebxK
z00>xgn0QvS$@HA~U=Fq~v9ilmft+K2gk#~FUsA-dC_g4Zj7|fTQ<{oG?Z6xW_GSbf
zHpT}l&+H_k%O71==hMO~k$i6pqY*CAk~QnGJvuN~QuuNHs3&h!%ac|4aQIGaU4B&-
zzXq)Y?~L1e4(z#}g;qZ(_2_%Y)#L44(RK1x2$IG%XDpkLK
z_I9I8AvNrA5`}c>M0}DOJ^|62c
zU9%`Gx}c*P<_YS+Vh*Vd$XE!CR2R=zMZ(OB5ZJyOH7dw#YzqQfM6FEcz!S+R~>troOsxmjt^`OwsfKa(nS=13Jz5W4M%}&g;;jYEuCt&
zRR-x~uBMDb&rs-0Au#9#koHK$&w``*^9i`-&)r)xWxBKU=8INLOkv#4oiBKHAgB(9
z2PE6nVS%lt-g)Hy*Ih>ZlDb~`^YL3*6pF&Mrt!b;$;yTybl5k3hz8;qupEe`y2cof
z0@5M4B0amRaFZ!fwdOCyjkjQVp=I&0{!;P<`Wts$fR&eJc{^4-ykSJUd_fT1neZc&
zHrRi&^E1%(h3A5?02_NrrYygr%f>VHQpj*_H>+U`<1#aMqoP827&l{7Kajb0clz(_
zkDC0^(71Wop-5W6<-1*V5hXX;1lG4lnpz0YZ1|45d*Fu{Dp>BAyP47Z(|2$7<-0`PBpEEuOD6~j7ASk@82ogov+<{UACWHYt!i~>m!d{
z;Ual(L5``YFGlUXPWTsRW(6K|7GecW`eR`~Y!_C4EsafVPwam@;hBPqIK=+B9g!`M
zc-CgL6xF^J=Nui_+=5!*n+G!R?CU#G`+r9dHk%e6%;Pc)SOKQ6x^}>4|DP_dOS8F!
z`56b+($oUeMK?v;cknKJ_|Cc@^3xXf8O`~_Q?)zB-Y5qJ(d2e&4g=Bl)wUZBQ+=
zwsrpe9LN=jlyi-~k?>E2$ikw*)AXkxx+`vUboOs;|CK;8VBxQGO%|x|wH~6$yKq(UNPM*MT9CbrF{bANLqo?e}f1-f+ARq?_Js-e2+6Fb+-a$r;VQOnx3^mxAOB-|#vcvDhMIwKA-Y~G^XwbOY$&k&$1=rkjU-Pgk910e`BGCz(atH
zEAin71_NMAjQ^Xzzaz6VgRU#^JkMN>C|u4_O;mfj5VilWHh%ZF$@YfEu8RQ|DyU8q->K#`+tR^RYn!$_@?Q=g6w%Khcpg0-KmSk@?$0fp}?Ml
z4R_B8Z*GtUv)`W_o$P5Kl!4&IQ*otCN~|B?t*)777bimk-Fl^zq(EGFFWh8YW@^A>
zY|!9X1~FNi1DO7h-g+gHLf+_08J5gvhboYAvEIq1ZA)l!Q5<__XGhXXJv_
z2vU!hmR1)4rlm+m)%HXklBC)IlGRj!%59l?4x@puyPJB3CD1tl3x)K%7eVx8lMet@
z4LA!ocDV~tmpNVx^(G@^1B?LS
zVh8EsV4eJ+qm1c#tWRO4tmzNF;w^@L2KF9YTNVBrlo*SxPUM*OTa
zSUNmB&batE3mZl#fB}g}Bpt^GgR|xj)u~`22L*ptVHJz;3Qo@D5}t+l6&trN{c6m7
z2?ZLBX2b{8O8opiC%~X)JO-=JKD;wfpaQ&
zSAMKIa;5>C&Ab|hE}KNv0v5iIaE}yYkXhz(fW+=jNHGE(|N4g9H2wxHwod#bA1hHv
zE>V(erHG*~#D)}O?UG>_JmI$3d@^P&?sqx^yUs#4pFr!AKpe)E%EZKQG>Oa811q)&
z>);C-YOtP}o=L=_n9F{xK)q~rFea1$z@)?aaLUhYx=75zjyUnYo5r$5Od%aAPJ{Km
z=H><*c3?d<*@2prS
z9?i68?0Jy2=0F!!fx{va7sSNzXkgDUi-^NzM5X(Piz(1>h%49uVFLj;G*pmvq6G#a
zTKHncy!8(3Oa!#M#EgwK9FG0$<;{MbcueI$%6$Pi2#SWvQURc(E9u^{(D>^-bN(Ni~~`{ME8R`G)+NMN_@l+ke;b+JqCU=3M
zELS8KS||hxLme#Jl$|ldL~Q7(B!;6lpvshqNm$Akn6{fR6~}iap?_mgC|8mwb~T}G
zC*|&@dhnvhh_-YKC=!ZI2kH$2;(xpEZkon!ChLZL!Q4Ws&IoH|z_V+xn+pkBpSYLp
zCcaXv02s)SvL|-pBw$*ged|;2(gD?vVU%+MItwq&t9W+`d2WBFYhrI%ZgXki>sKn4
zMZ;2O5L)3LQd$_`cE9`H--M-o;K9yjBN#vA>B2|k++^Xxle(}sLEB&2w;s9g{~L&3
z+pnu_-+m?YGGX7FZe$af0%vMzmx}lrfiZug+IRmXY)K&65iwbH6V{!8KIwIj^}1A3
z9>~EI`)JLzEN8MJw>V)|d8jfc&*CG-QvsdDU@&Hc(TZpY6Tzg=v7UI3h%IPhi$nK}Lh!i2Nn>4+gBym(!f;*Odn^szABlk=4pGiD&IXT7Zy1q`i;soks~
zFnkkzBNi}+>w{;*Ap|RqAGyHZ5~*N_Dw>XD*o!j8PRSw=-1DZ`~)0m?bgr2nsu^pIL?)iIm9HO=Zc
z?!9e`5vML|!|OzakW4o~`hFq(8xIahqEpG=l6+t>G2YEJnF9
zv(?7rFKx`s}Q6P^ZP+?+Z6r=JkFEQN-
zmM!frQ42ay2D}xp5GJZ7i>m5V0ObD(&iJW@W#C|~&2Ly&cz|47>YzlUY1xCex7DX6
zLby`7>znD7C}MN@+~|B#BZZ$!5+m7e&FNa
zjsjW>CuuflH?kuIq?3I&R=!}QHEsgPnopF=o1>7SmRvdUF05Ah60g3eG$3XAx+na%l#pIhJ0xDQwYKgv++9kS#C2ulEa0
z)Zw*6UPIWp)`LhuG<
zae61JPJk`VYJae+oZua2zSpxiepP3Wb@l!#bkA$Ix7uXz>iY1o;h4a-&yL;rqVI7h
z6WxTYKf&7*GwpxWAM9V>Y21)mlBqZO8h1xiam2VPL3{hbmff-pV{aj$e@Su5ZvAVy
z%(eYv`{3P)jerTCKUYmx_m|3kC%oSV{aai$Sv48i@7e#hFEr-BKK4B`ZW|x|P
z*`w@bZs#wWY_;$28||0v-`l&;r!}`fnJ~HExe3@a*%jDX+-Uz>w$_y!2U3S+#Wz2wRhYSp-+P}HFnziui!atc-vLv5!{L{?GW<%=
zkg#_7CD)lc6>05v%j%~{`N|zr`f-xybuf>^@*lh330TaYTzFbYXy0q=xQiw)HmN^%
z?x|BW8om8fB42xJYpUQ}rsCLXi?$MfWmurq6+kgHC-D5xR-j=5NpT*UDv>M|_+b_S
z=@c@sC^37l(Jq}(|2ai9iSPc%^Udrg`b&Xg!{av|p>t^3oUNb|Twd6o(ebSmVN`W|
zTA9LafABNeHqRele5&1&A9^hF{Lv3P>zx?ue(az0^7Ff6Wo;Q|KV(aD>%E~4#C)s%
zNPQxdx_cMqWnOz^t|i6umg%a1rX-RqbjonY?)1`%>b0!4VGA0M$Qi9=b(Q%OevLgk
zNcE8-^|1EuJIgi|x6RVTHfMI)#~AVM4^KCi{{i`42{4k&2iP$#ZmsA_mV?Lv9Hew
zw~OmC@=_na*A$XB+XV@Qb={r9`mwVl1qMe&EH
z?Wyc*Obc6e%UjK*Qi%eV#+Od**sSnPp&>(2h9e>+1efiLsjHqpllRS4ih27qQmypU
zQ1#38ot%OM2wxUZ&F{xj{J^z$Y43h5Zk6?1iPiUZztcH92~aRBxmYeOZbb&u{P$!J
zFOW!Oa1DncUK@t_65#tC^r3;z5Zhvr#<~v@s$CtGG++Lje6H~CSYiJyXl?aczL>`QtgWBf(Ph@3
z>@sfg4RmY1%bpg(?cH5m$VOut0sIahMHq07(A{&|}e
zM>4GZ?dt})J9pmqU3)fi@jLM_*SGpUBW2X
zO;`6T0K3_l^J8n6O#P$03XgrR$`o-uzOMNvBPgAsc^0~?a$kp4N7%OWIxl#_*|f#>
z+rXQ*r)B&NXv(g+IDQ4f7d}?M-7#p6x7gwAv^!V$xv#a4z_@uZ4g)<
zsp?3~d-`QMq(1$^vhrnU5Ij~imPGP)aO
zYbRrEZt{MSjH|!VUoz-)_B(GC=Wtvx?Sh<%(?1{&}ehxdi3xW)t3?4A09b>
z{W<7Lt=nbLZ=cgV<7y)9^zzq@n+3gMgrrZKpZn^qgLgLQ3*wL4aCk6f_rN
z6(GVh$D*Zr9yfmgclFyTC{7nY7q@HBdKy)f_=z|!U}aV0E^3-S_8t6!FL=F>b)o|H
zp|P;N|89euVRMEA?VoT^(SGU?3GUpVl*7v(8HcUxY;IMwvFeW9%bom%TP?B)-8R+J
z{FwKQvYtMjb9H*Z-S$B~jL%@h%pRUGDT3xLY#_bh{@yMq&8IX(s&A2*&$_GWAZGlU
zUCrhBWtZjVeDh7!_oaK)w$gFROMr(T4o>i_J{S5jY<(x9qVvKB#os5E_@8Du4-svC
zJ(e@+@OsCu@VzCW!yqj}+i^GH!%~1-%lV0)tMBXiGxy4$-W4&KuVkA#OWR$Kx_L#)
zX|EEM{WjJ!vfHohFW@#=_3g%E@Wb6?^FEFHzh%U4xu24>&yRZe`f>QKaBYiKttb6~
za2wpwUnj5o(rt$a`ZwNwaPBxADm9#aAv@
z71#bH^{iICZggy{`Nx&NK|9xCd)#@}Hy#@P-C-OXDc~D_u{PB1l6~Bv{B3Cm82@4k
zx)tGe7wWK9tWq)t3~B`s$7g%hnJ@B391;uVt8Xa(nmvO@w;TrXEA_oExBvL0lGv(FqY)y^X$lXoz@EfrjNcx}n3Buy@?lb;f
zxPQTbiXXUmNxW!dkXr@*Hv2x;x;9(-mB>9jnLe;A5Oj6HFU|I|U~A9?wvE~DW}01$
z<>lwE)#Yyh3Lbi;%yU{UC%XvS^e}mkN~U*S^IMlaXZS~Gl2}(V{l=_03;$}vYj40V
zKpPbV@RJgEJ&E6+o%y4MpV9p+b7sRSbd90>mtIu#bn#Jw&DtT=@BP_D!A+U*zT~$%
z+{V&;>-@0enrbBqiq*A_Z8|3|)_ZdEw|QkbUtmRk`6saWq4&CVj9&`n5;|R-aXK?V2{5sNuunYYE&kz|v5mUO>S15`W<#n6aSH$m4eQirOJDfXsU$Zv
z;uvJnZBQX@EO|xhOkmiJ6WYZqm4Ee2l)b!oxtm7~T<#ox-ev8Jh))&6-N?OQo
z;PxJjP5=W=ig~knu=nc2?3c#xh$tPpKu@|Y|Gry(d~Ryz!GjRD2~pY?SB3hL-?_?G
z=6~HX{=zRIXt7}#2562Q!J={r)8B)aUb_NnTI+LJkQr{2(Jac9}`)5}0=xEVX7t!Q73%8R;GtNmOO{QNJM~l2}
za%~g6HPDbI%merl-kZ2p)T1jv#w%P0
zBYgWzxfJd6N-A{aueI6pk0o6lEh8b>sG8@z=@GHh%?d{)Ae%8US5L}$Z%=K1QFErR
zfH@sMerwVA(GrPp_O(;(9)nr^Eia~eO}CEfS*zVbI2Qi3=NhWQj-|QuspO6*=+8b4r+L>DqUK|11Q^mXLDfEu?)OBECDe^dj`bLod6+XO*#hNSBI}
zzc1G>%+i!kbqt+ziR8%5P(SBF71|qozNI&}Lyo1~8_XIFfS$M)hB+1
z5y8IauJZ1#8GT^N%~=37v8L?n)Ke#<{P@qL`zFCYm(JAd%Aa`lu$L`7*y@^|mFJuL
zr@(x91yJp+Ao(q-S48cGBCYz^*e~QB@clYx%vBLnAN$kpTe|G^H!qAo
zc{QYzT@NWarC1;NlPj8ktib#B!@^Ev_r|Tx{Osf2HApBcV}4KYy3sS^qIxaPx;`%bOGrZM2R_XJOG#r+XD
ze+$rFFE$wbTd}bWeN_ERueZ6`+}dV8;O*22OYKnFP{1j@H`R^$6lnUwqM6rNA?Ivw}~kRKEB%*R5(!i@YiPZ1mWP`VNQMb*#%3zy2uBsej$7^`poB`7!Z
zKj(qrm(9;t(MONm)(!Lv6M1vga3mmn%TF$p`|O|m5$MO4^OD8DW6JMJoF<~cn|f#-
zu1>{Vp?BXe@RZg6Y2jOqE%9|e6XE_GkUr-!)yA&JCnUcA=U<|emqo`X9ly-dnp{sS
z7C3hI`iSes-Sq-r^XB2_Yo~UAK9aJ}AKz74o0Gq~^ee)|_tMbYaW#<_{DKeF5UFt(%NkG;3L;3;mBeK-E!v5L!O
z+Q`3@!!9w!{_juJA@h$&D0lo~Ha+=tU3c%;jduBTO)C{OUs=g(&-AU`DJcE-$Z-+{
z=b=*{3%Gl9u0mnt)`up6t4%Yxva$Wyl{{1HFCKl5yqrm!sxRM$k6+9_^MJj5+43GH8+}htBG}TvOx6d6$$#fou>~1Ju5*jHf#nlxB%%K}bXF{g=
z3{%3!EahFDT16R~Dd87lHBC!Gj~yMWuT44bd26`sJx|c2$s0$)P)BePBb%AqS2hZj
zxF@Ga$W-x1evRHjDOZi8T^??zy`p{INw57oA%WT#hE<^7b?d^%9pbD)14qs$><+yY
z$j|!pJHsNzJv(`FKRN`#n`oC{lPY4BEVIh0jF;L4r9L<^^#neF7u(wXYDshSecd>F
zv?ue$?c|_nRDjtU64A^>E*Sn7_tsIpQqHs}@0V!*JdMj3>&idXrn=Df`7+$=&~T?u
zim8`O#Ivqx;>Ik@=j@|5hPtlOb}6ja1J3t7yP6$abvoXi;|nXg)XdX*Pdh_d7hgrF
zkYiYVNgi9)-)ZnWHb&1q`D2s-fUPC(&!ZgXv6?Z8+chFCE&)!W!WRW8np*Rh6{_p6
zC9RHr*^T6iFcjEL1&9IeougioiwzEocPXF6^l+{L@=
zLdafN73}>w`BK6KvYBA^R!f8D!ais9NG_;?x)`eUhMF0RAmrRmp@5VZ5&VYn9%!n$^IIrP2oiDwZ5t4JN2m_wg)t<
zQR8uaXc`e55*v?>AFKG?rxY{C%CZy?#aG%bH7Sym@B1*rVn*<8lCz%?Z#U^r`8rM7
zxGQ5%6(n~gj6J@5^TY!3=VatP#2
zC(63g@h|r!-5TUH`WFI>KN6xTTUD=ByOjRSGMaTfgUlUc5H5ITJ+B
zDE|@aCe(fB=~a>ZL6J$Fo6s_oxP->*cX+cdn_h-}e`Ujb`L;eQw;_~g@>SN-&JPXQ
z3wP>XK59{&y{2(0kK`Y>&-wSG-0EYut@;~mlJeeH`G9NYQ|I8nT8_+8nXCFT
z*|W7jewRy@-|*i$dUtjk8@|py-{jrCAQ=AMSuch&Ri5^=CcZ-a_38XWiX$Cxg{AuW
zt%~G_@5)TgUcFq{@YT_SJ&ER=asolQJhd;<_Eda$f
zqn?NOz@0|W@svRheB=DIL#8{&B-v=7i`<@mam?HFc?-{s*ZPC@1%$J*taT5m&ad=t
z=ZS7s>X$0ve5NM*c;UyJ32E{bPj7$yqkU+FPk(dJZv3C}*N2h^#~ULT3D$jUmqUJT
zOQZJt>y!j@ZBXw4%89#gAP+52<0MDXo5HGbp=)}VR{5DHN4*#I$@#>Ll!^>Db!l9jOkvRjIm8x7f$kF*d(tKYZ>I$!m
z8&OalHr@QaUSEF=j4zj7epAA6_+vLM*WA#WbaV*A|8$~f>q`F#DV6Mj$rN3=FBPBr
zgmo_+`Sj`y_pL&q1b}begYQOe_Z+R~XpgO9LC%*wCGr;0$u<7WLQ<+w)|evSY8
z9Q}#a_-Zlg59iNF{{Ai_ZT7T5{Z;Zf(+ya7A|m1O
zCmtToLNg7wGne?z#fsH2jtKU@c;_iDhbfWcIrRnXGIcGmN|7YJ|G
z3a2=6Xt7nFu{+AeXX!$6zG3$p6P`hSGsoR-p0ys*nNy
zoUC~K@8KjPdR!22zi5U-V
z{8@{Vu70@ip~6dk<*zfXXg=+xe?~3W!1zc=BjrN5Vz4pS2vf&0obBGB@@>4`tIa2E
zZ=3a~rDk-;HP+_ykW%EIs9gV!_OSn~0i=;R*;(*N53^*8sd%YV}w7Ah}#
zDbvY_i9tF%j`{jd8jAM#JU(%&x5=+en2#`@n#g77$4#GkF#~!|>01AxEk^rh$!#x!
z9DaY!|L5MGobAViiLVV0mg<&+gLK-URx07=Q=6p{p8qa=*6NaE82jSmg>K52YiV$--Eobw3}YYyl9)7w_TeB8LJZ6Pj0r1IHhEE^buM^
zwW!m!z)1R-si0Ly7VyxYJ)xnDO1^O+pQ(|DiTXzn*T*Y*G_(Yn^v8a5<95H+txTT<
z+}{VeN3T*)SnF{5;4NZKn9_*X?SV+0e
zw@<1wj$ixA7P+r*$Wy`26=j`d>#w>$VG?B0yKUXBtIueZuhYf<^gg~V^sg05Yl7gN
z4V0^oV!pp0?8!2xV|X~PlMzLs-(}P9o`cC@OB8r|Y9nKL<7=lE%c=r*^;3OsyGtrH
zzT}90H?G-WNeO~iyj=b%3fU6-b$$r+#mFA@6f#n6CV$4`tuQ)2#3IOk(WA+I&-F?ryo4Cy~TIj
zoH)oEPo>{UeOZuyPu_0rF?k7#VuiU*fTh_xx
z+KmDpoogn_O%ypP>RR5=xtfz7Xl5#QwxeHn+a>$MwMzQZB+An3r^!oG*t$#MoAHFp
z7erKf(rR|Jel3+=QShI)?HS_J&Z&K?5YX99IJ?2^fT3cZ$Ws4xpo33kyw0}Uo~9Mi
z3iMAq%sle>WpP^H%yE5n^qN=%cTln0oz7tQl%M8}qKAfP4MnF0eJ~FY1406BUsng+
z#K0L{=Atz}iB{gLGx^|okHo}9tw_b7_p=*(*&(Gy@9+B^(|c~M9~-H7Y4BOyg?l}}
z%@0mMzmmRLVSa;3m8zhevrZDr?9Wpjku)~TuQ&f^&)j^!FjR*45*8t5`OWg;r}BQc
z;s=$#^4;=J&j|`Bg-|zZ76?gLY0hlO^~ReQJxAY7jY)EO!h<(nTu2zdw~)POEZ~s1
zcE#x@QkEXZe7J{gu}}7;rxae)9Z$1=IAYh8WLq+bY$Jc8%e)gmX%1h{K`h(!JJ;POyi?4cav8~Hf%
zRxqgATO5E)yy@
zKg~lHhf@VvYzr&e2?7JZfH5XqXnKS)@GTKQMXvR&ngo^=iAs{+P(&5|c^)>*zJDnz
zkXFRU#N5OX;;kAlE8Z}KT}#$*=6@KeTk^VuN7(}+V9eV9jdE6@%Jm0?PdYH3n+>ZeO2uu0$KO71F7G?G5^Yx7K8_
zPQf9a%c%mEeJbCoJ0WH@^ZbQu`_Kf|BPw;V6JeE5C?tG?_f|stg{jL|k&ly)ADt3D
z-5Y~Vw%y_OY66}-%p!Jm=HtJ-{fF3g#3I-3WFE5PQ>s;~=k_|Cw)LlT<;iuMd8liU
zct381@mh%rzpty%G_Am7O^Pp5gqw$
z<;94O;`Ymoor`yW8W(Rbc9MK_G3-6)$7KWKu5Yum#TTCD^&YJXixPpVr)NBOvMMQU
zN$G6`{b<~<(#O&(_wW4|uA@}IvB_6V=i`8gG1bnV3NYglII*~tKXjFCsMxr&Q+
z=X>gXSJVy9jNQRY{3|BDQePB&!ygH)f0{6KLBQ)(jVWl>Jt_*UbR7i)ufD1(C)
zI=u>`L#_2NE0GbDW|7n*yYi5u$te6(=JMYYQ)Y6$V@KMO
zTIAzRDpH-MdnuONx!}M}x){pdJjtIj;+ysQJYR#vu?T1Z-u5JV~4{>)&
zc400yO|C~sI%?&%FoUE7PI!4k)F8Y;6~||eTAr#9qr`mo`C>|FEi+)3r=1Yp8BZ&I
z2;Y_#Cro21X|}v;j-Axfi?&&hktt+s#6O8o-|?270J
z!tS;SA@T9+!Q*j!4W7MF;GcMOqV8&;d^*$GA;FDa&4M_F6I9#dZ$e|s_FPL)-RG~B
z_gtV{+Vp@H(DC+RNh=n5AOa$qFdz71U&$mlbV{%7pY&%;5s)Ccdi&I``$PP`v}dim
z*o>K5i6H{BU8^PRhj2?|Paczgf%yv6-qWS^1sDq4HQ+^A6Jlq)
z{cj`wctq#Z->veO6^~s1?`7F>JO-4!<5twupT2^EJQbS)K49^7x
zN~hs$?W(5eWyr(GL-F#r;PSBA2kNGrqIjI`;R-q}`<@Rz8jCbz{Bt<%K2(|g=Umb1
zH~4mXGCV~{`#kC}($RTQd{&}Wbs|a_wu4h2S{vgwjbCsk?P`#365f0pduhJnCT?p~
zK{!I!f3`9$;V&}>oL-<+!~JBro*NQbc}UduRi``9fy_s@04pK;tt?tQ%Y{tD$*=y~5$X2~{V0(uBX(`US&<5I2u4i%VITdcjLcAD3a1>g
zgd&(}=!^jJM97;&gveXZ9%BWgzUiGX+T!0^;}$`x(A)a+yrEwyhq|x7U`KXgj*6aB
z;;B(N9`6)z
zAPb5X9Tz&Bwg3UnbE`rC*5zigUY!47uI}d=`_CoGxcT>4$P9Dha0GVC{@;jbzz3$x
ztiuwj=?+J%0YX;wKNcBZ0EcY^9uv?KwxdWRCfBE6|Gb@)C{Eb2IuZ;Qwjgf~!%txe
zC9KHISt=pRg0{@JmoedMw@Ndh5P|8=C{IiD4&;5dBcd)iDrPQFGK&19aj7L(!RZtK
zS1neubODjp2iUrO=H1mOFi5&C74QJXiM%lo^*B8HOK~0esJJ-eyYOIR5*%>oK!_If
z<%|d&ZD9_V90yobCW*c|2Bfcb=_WMEU8ofAL5`KTa9Zu|+)`PsA?HG8;&_v+*bn82
zNb3=F?o)k>FkH%BA0QjwYH{{@^YZ7L6M#h%jP;;h-yBB5a$2+dUXfVDKVZNPbWm$T
zS}QqIT3ThpdaTX>1dLd3i29s|qIeUKcdBpgIn>NQF_tLsY(!ph5>CrbdC{s6kEMKT
zx`!>6kgRChIVOA$R^CGX&^)^J?ZbYHTl0=*)Vr=UrxAbNEYk4<*!ioda$niX94KL2
zK2iBs?{+b1XG)D;l=hDsPCFhNg3~?nNZ!flU~ytYQe8Psqb#DZWX-u6ie<90-7yB3
zx?`J6e|i|-SoT;E#Q#EOXsKSYfoKPzkt67(Ru&Im5c2%|a3MbK+b0n`c(S*}FX!@%
zMGa_sKZl2y*rB&v%ITxr}n1|m{Tafuz}GcTo@)Vstk(=Ldna+&%}XOw3*`jFUUr*!gf
z!ny8mzc->QPu8D^M^+Q#2$GEuo9S`X2*c$x^2aW(xWZ$rmG
zxX~U}Juqpe`B*J5KMlv-r*4>wK507l5n~a<<$9`h^|
z*BVcKZJc4{vYt$nwhYFJez&XPeOmWNWWzM~{$4-QR{Y!KW}KnrIIP#C4gsKqjU8pv
zJEIslATr;X{?v2+VK)3rj5Kv#@^cb
zUvdj{0d#FcKd{oS1b<@X@v}?>
z4vV4(g6KaW(K!85;jbc0u7eOI+??}8d72(#6S9X0HcS%;_FmNY#sBtqW7f{y1JJ|7QheEY%nF>yT(hQS>
zMhH%c6_Ka*Rty3SEd&2T2RtY66t@|G4`|71uk&j_v91)Pt>OnTqDRa{82H(VXIKie
z=k`2*Ow+DTC~Xdu_P0y0^>&sP1?<@_>urHRw6gN_^CK^MD^b5J>UkJ8emom<5>OtEXPo_-hY0T;g{
z)$4nM9@7;B)|rW2SH1q1-R-bazdaHUkszVM>5-uT^9V3V+J8A{65_fLm)lU$mq0`XTIz8Gu@MyEVB@B1pI+FJtPcfFFW=DJLd!
z?ztF^mPoT&!~N`T^`j+gV2%4B$$9~W!8K;L$t^@?4trQiV59J}stxaeRJ@ySpvf=g
zpBmH+eYU=KZGi_%pEC-1Q=A?Gz)u)|je#B;QQMGBm-7XD)|=Vcs3
zQ>P{jz03U7PD?ZsJ!&`!1wUbC@Sze6KUV6?92+JmBa6uu?VuJd-mmnHpsY?Ctx2IF
zZQFmNMrg6T3x#ld#GaP>ZY}z;Tjug^Y+3=v7~yr_HQt1Qw7z*tuX8lv;`Z%%DOhi=
zw&|`tPh4xJ#zi0dgpw5N*jg2T?xu0habE47CFAxy+Sd$I(KDylu(dVT9^j;lO3JS*
z`Lb>p8?MPcO*!v2oBTREkmJv+YTY*E0E<4!H;(=m69V*Z)tQx2R&fM+lw6ID@v&fe
z-Va$-s4}sub|8WBkTPaJ23Gs~!5$`_Y=l~mfu6qzzMn7Y%taft_)!+1gezbz5AKeXnMbA84;56nFIU0m72)$pNruS!4JP
zxZ!P%|4S-_81JM}XL0pxL<15ztYK2w?d!hVpQiA&vcU3a7|u
zv}$Yw7xtnZk8JGj2QgR=;(R?K?@v2QDPM(xKLDj^oath8m4&?Ec5Y2q_m^G9Tw2!w4+y!N=u7ltt7S=J=%41GAPshbZ
zAjnHylU82W{yb2{apQ7(?Qr_SODucH&|WLit{8lPDsOE8A(!i$u`@`$)-W$P3^dHu
zzo4i~^FYywG{C6jTEX^A}H1;yPLq!0(u5jtzTc3nkP%>?D+FwP9*@Q<)~l(LYat
zMwC1)`?tx}2RzChPv%=;TOKRFV>FJaiHq0RhSpjbdkL_2;9ByRQ3@%J)kBt`7thJg
zsUg?8$poZCtcff8*T=4*gU}VXB(Q-ciRmRZ?+z}Ck|tC|j99U2;ATr)cmSw8o%NhH
z!ML{)RWF>CKQ5s0F{4aasg-fsI%GJ0)1@#rW3=+t)UP`!TL4L!wOv9n?>T-40Euy^6dXt^3do$u#M=#Gn3R;-6}jLCU#$
z%X~(;XzJ*bB&l~Rs~33pXc+r*v`oK#aSxdH~^BXkG^uY&_%%zJUXFeS?x)6EN)W<+7F*;w^(2@5j?D#7s?0udq*5~
zsU7LQW{|E2Zn-~;1`L7lCZW0C(HpUNR(<)fwoyijDX7WI-XKQkI38Iu{y!?LY>HCW
z{RW`7t5YV>%rJ7_~{1y4l_4S*$=HC93-fCuF@078sI{m
z4gahOLeszdH1A^oyKp%Z>nZRJ{QHm_-Z0htUDUb#pqLJel}+_UHR4wG-o1kqKe@lK
z8q7^s9K>I&S$&Pz#u*1|)U-(GCX$bOMrMDnV+y2;+A?VwA)Xsa+}~S$y>uoYI}E5D
zw46NU6kp1j1_(D!2@Ds=*k7FQE4;TAe=zelK1p1E&iG1n0VOxs$_rlc57~zW@Wfn^
zl*Pa5THS^G(7=X=>J~d~WtFG^h!S!sjdX|^4{B14i6f)eygh$T0rlS3lOc{FaU2E5E`Q?DSb5ToJ*Q{Nh~-%Z0o~ue{`KG1Tht#sdb;
zLN}_~n$4_^L1AO#KzD8L8`veSxAS0jG-4lTe3~J!pd;u250OeSQo*!n@HU4W}=^f4h3aMY||AQe0d*+^SAuYNHPXG=?VFWW}X0~!1+Idxxo#}PD!ew
z7m5AX8WAAs*c%Zy%K3-q@cHKeX$p_sNKhr}P3Q*B`fj=ONu4dxkv_9#rHO-7(rKmn
z&A2@SjagScy%!XBvx}VY>wjT3+E&w%&Ko%G2S=pnu`K8+oWM_3WJKYGgThy7xf;q5
zC~@&f4WRJ^rChre^lY)2NXj;+@zDD*D#@VMJ)HVM#ZVww3-cys=q#9ZIrjwf|c5K-w7fRA8WgY=Lsc=W7qgnAs0sr*+Chu5UyW_9;
z^SuOBN3BmJ)AJvi$4!C`q+w~$(lqHk%vJ?evAdNDPtbl-(c!4F=KNMGDg;%wJ`jM_L^faY&f;9N1
z@im|0HLFQvf%Q6&%e5aINlb_B>su-+I>jjJEUbJeD@3n2eutnEiVQdfa)K#kpW>=4
zBNSM<)TGZ6J9n(D`@mgCxPSY_Jh7Z})Z|KbzruoT47EYmbsjJh7G*v5b!kH>5by{7
zJER(-&5lLTK&cwM?3FcmrBIuhX!faXu!f7U#iSe>_Nsdg0+qwILLHyT{WR*ocY%wW
zvUpDH_fDTCm{Pspg$6CzPca*yu>2Mu|cygf-fj`U9Jax$Hy-}
z&W-weLlhaUi&z8`K)Bt8Z!Fjenpi`*LZia+;N}J+sH-y+s=C>wjm2cDB67~ybT>EL
zF>#|^B#G;GelmM_JU#ZV!zYd8I>Vw;~+**tiHSuUxcLELfc{i)r
z&9y`w!sSZpo$WL>1S}VU3zVxr+`Jn76R5hMh69EdKQy!3;~a|x%;#$|jf3v3!imIG
zxhKi)G|+#P8wzUG0%Hl|M$E8*Y|yEvlsVXd$C!^d*ADK
z|JHGTKfj1(03hg)&yRsW{y<&}001z?bJgUZkH4?)j~yn)OMpqoKQYjFi+?6M>iA(F
z7@0zat$7JBF&_U3jIi@(|6BS$#>QNW`v1+CnC!D=f*O9
zW0b!M|8M!bf8@~rmiPT5$3~xxHs<;9A35q@$9;`5(BcbCZ5|8B?@q^MB>)o*?Ia}?%=xe
zeACHoULuoU8hcI8&n$pX6yFwY++g>zq>4XS<>DW~-?wGn!P4w5?Dy2`mYI8pAB_Yx
z)Llnzb3!&%!~Dev|19(GKCTvo5Z6a+!N={+d2~G&&@jFlX;^)<>bbJ|VKs9#VR`k<
z>ekf+?doddBQ)>pTK5H=i{U22)Zj!Uin)rkHV#D>{*@|B{V0)t~vIp_!Y4#p2SnE@+pn_%F>lwh))*AJtd_H(c)Zyg(vOv!A|BK)>keDKspX}
zT=OCX*k+@E^GzW+C50+6q9`y0}rMF+7Ns+0lo^*pt
zv=Ks?>eOzz-P9w?q4G*OiM&;@gF?f&gDi`|*{0UE9Y2^^T6fyK%k!RC;#fK)%4m)l4El(8%9Jc2LXiJBZ9rW)Xt2%m!#1NrtkWOaU
zFYSTlxT4zcDYT)g7?o;yU5BL!Hq{(;35P`^l5YaE7dJ0;YjB}$6{#W%$21^ayhDIs
zSTIA;od>tQdf!ZS=hX&kKn$bn~ip45MOkZf_q^<{7Ql?zUFO{KlI{QS13krio>!U+?=4hB(tw?e_r;>{$
zP0iEdt10ba^Y6ZoaZti|rW7yrbx`sC@wFE!*y2cgZ>ETAgY+Ue2uze-x0C3%t!Oyt
z5LHfr=J#MF8&C-=P1po>z6H%S51+xZ7ISI*3_MM$QZJTWuIs^CiCNTAX$Z;Jeq!%E
z1M=ls3kx!miI|WAkS6|?h(Bou-vuf(5S)XB$O@jd*+InJM}?WNiz-V4w^?f=^h43;
zcVpcy3p3F^y1RE3Q+?%CoS_J`c8Gk{JRf7t?^9n_D7xpoX;!{^49-}Yp^hHuEf>+G
zlLdmyv{0sbhXN#w#0^=uAyG5W_R8iqVg(P5mPrM@+l}U
z-gPtdgjh1Pe3~?XLh#+0m5d|r)hxLuWg@BdCvSc0s_*uSO3u^B^ZI~FE
zk1aIRUk;X+Nz+f4iz8k2Y7x591G+~Z)2pHeTW7eA3T~C4t;hCtnn(}#4Tev;9jm}Xf}}`?gRT^{-(aKL-fm2d#!1;
z(z%FuS8+jxD>+gtHRpFzD|+tqLYO1HJy6{0vmVaSZ@tp-I*xc^X+Aaw@9dew!bu{t
zKIpdx5GdAGtpu={C1p=Kt^=60soK12mpwAkafA8-;w3a`|3j1ZO`}{+blJOmy&Pk}
zY3J&-5!$)MyV~xFg1kk8E2n{Ps8vhnKL52c)io4stne;*djH532&uxTL)LeBJ_$`U
z0ci?>7DYhVj?A=gcA9l`ZtSqa^9{Qltvj&pFtd&haD{W%+41Nrf6ZrVaWh}5jU}aV
zN&^?QRDRc-KyF}5r-O8N@G}($UIhE{cyT(MRmqNpD~A_iD>!QC+#Sv3K;MOmslz)W
zZ(&-Gjb->L0m}6j2pf?N0!V+xhgNRYwNab<(-G;wppbKTX(=!oZK8RO8{D=eSJV$z
zZj2>(gIE1
zJ%WqV1zJ^!R7b&+qJCK3{vse|Z>h-L|7@@M4KF?J%!B2Vdg_3{@xB$)!htv*L2v)b
z!@p;2T^QFoyQ-R4fnw5xsO+*`qx#Ex4eZLryPAIKY}bPsnnacvchs?5RuU75qw@B!
z6m3vyFv?I{v#TsupIeJhxtH^DVEG
z&L6C8?`%J4Y7^B<=*$Y>VVT<4^*nwSS6=@|EzU~1QCB)m(m|OV1&iW5HFAL8&9R2^
zNBS$3_3%PQ8ER}P9uMWg7rOS~1DtPc%tjE8RE)JrioKBpCe!9mJ5XJKekj7&Gzsdp
zABKLghNm!HOu&eyL`>vDU7YWi-b1{Sn3%hGHEwkXs*R15POOSaJltq3`9!wLT+!Pz
z-YqtqT53